АДРЕСНЫЙ РЕЖИМ -Step-5 - feat(assistant): стабилизация свободного LLM-роутинга, прическа маршрутов chat/address, прототип прогноза НДС

This commit is contained in:
dctouch 2026-04-08 23:40:12 +03:00
parent 71762af575
commit df29798fa2
47 changed files with 11439 additions and 651 deletions

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,15 @@
- domain live-gate: `docs/ADDRESS/runs/2026-04-08_Address_Batch2_Lifecycle_FullGate_PhaseC_PostFix2/run_summary.json` (`strict 36/36`)
- targeted code gate: `addressQueryRuntimeM23.test.ts + assistantAddressFollowupContext.test.ts = 223/223`, `build=PASS`
- global non-regression: `docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_13-19-24/nightly_summary.json` (`stress 102/102`, `followup 25/25`, comparator PASS)
- Принят `domain scope freeze`: новые домены временно не расширяем; фокус смещен на Step-5 (архитектура + UX + качество ответов).
- Step-5 bootstrap (2026-04-08):
- UX compose first-line direct-answer patch внедрен;
- targeted tests: `220/220 + 3/3`;
- live smoke: `docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json` (`strict 6/6`).
- Step-5 living router increment (2026-04-08):
- added `FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1` (default `true`);
- added chat mode (`chat`) for non-data conversational messages with safe fallback to deep pipeline;
- added OpenAI-compatible `chat()` in `OpenAIResponsesClient` (with local `/responses` -> `/chat/completions` fallback).
- Финальный stress-pack: `102/102`
`docs/ADDRESS/runs/2026-04-02_Address_Slang_Live_Stress_2026-04-02_12-57-27/run_summary.json`
- Финальный follow-up pack: `25/25`
@ -37,7 +46,7 @@
`docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_13-19-24/nightly_summary.json`
- Task Scheduler: `NDC_ADDRESS_Nightly_Regression` временно `Disabled` (ручной режим до стабилизации infra-канала).
- Текущий production-контур: `question_mode=address_query`, live-first через MCP.
- Следующий этап: `Step-4` domain expansion по рельсовой модели Step-0.
- Следующий этап: `Step-5` Architecture + UX Quality (LLM-first валидация входа, улучшение пользовательского ответа, без расширения domain scope).
## Что реально реализовано в коде (срез 2026-04-08)
@ -83,6 +92,7 @@
- `step0_preprod_rail_plan_v1.md` - обязательный pre-prod рельсовый этап перед массовым расширением доменов.
- `step0_closeout_2026-04-02.md` - факт закрытия Step-0 с артефактами и gate-подтверждением.
- `domain_expansion_implementation_plan_v1.md` - план `Step-4`.
- `step5_architecture_ux_quality_plan_v1_2026-04-08.md` - план `Step-5` (LLM-first input validation, UX, качество ответов).
- `general_domain_questions_analysis_plan_v1_2026-04-02.md` - глубокий разбор общего домена (40 вопросов), route-модель и batch-план внедрения.
- `management_route_probe_report_g1_2026-04-02.md` - live Batch-0 probe по первой группе общего домена (Q1Q5) с route-верификацией через MCP/1С.
- `complex_questions_status_and_reuse_map_2026-04-02.md` - сверка кода/доков по "сложным вопросам": что реализовано, что detection-only, и как переиспользовать в продуктовом плане.
@ -133,3 +143,11 @@
- `docs/ADDRESS/runs/2026-04-02_Address_Followup_Context_Chains_2026-04-02_19-15-Run5/`
- `docs/ADDRESS/runs/2026-04-02_Address_Domain_ContractsOpenItems_Reference_Acceptance_2026-04-02_17-00-22/`
- `docs/ADDRESS/runs/2026-04-02_Address_Nightly_Regression_2026-04-02_17-35-00/`
- Step-5 follow-up hardening (2026-04-08):
- `address_dialog_continuation_contract_v2` for context continuation/switching.
- Safe retry on retryable limited outcomes (`missing_anchor` / `empty_match`) using raw user message + preserved context.
- Regression confirmation: `246/246` PASS (`assistantAddressFollowupContext`, `addressQueryRuntimeM23`, `assistantAddressLlmPredecompose`).
- Step-5 living router validation (2026-04-08):
- `assistantLivingRouter.test.ts`: `4/4` PASS
- `assistantLivingChatMode.test.ts`: `1/1` PASS
- build: PASS

View File

@ -4,6 +4,17 @@
Контур: `question_mode=address_query` (live-first, MCP)
Стартовая база качества: закрытый этап стабилизации (`102/102` stress, `25/25` follow-up).
## 0. Status Update (2026-04-08)
План Step-4 остается референсом, но на текущем этапе введен `domain scope freeze`:
- новые домены временно не расширяем;
- текущий охват (Batch-1/Batch-2/Batch-3) зафиксирован как рабочий baseline;
- приоритет смещен на Step-5: архитектура, LLM-first валидация пользовательских запросов и качество ответов.
Рабочий документ следующего этапа:
`docs/ADDRESS/address_query/step5_architecture_ux_quality_plan_v1_2026-04-08.md`.
## 1. Цель и рамки
Цель этапа: расширять покрытие доменов/интентов без деградации уже стабильного ядра.

View File

@ -14,8 +14,8 @@ flowchart LR
E["Auto-nightly Incident<br/>2026-04-08 09:52<br/>MCP fetch failed"]
F["Targeted Fix<br/>S019 predecompose anchor drift"]
G["Manual Control Nightly Recheck<br/>2026-04-08 13:19<br/>102/102 + 25/25 PASS"]
H["CURRENT STATE<br/>BATCH2_PHASE_C_CLOSED"]
I["NEXT STEP<br/>Batch-4/5 domain scoping<br/>Debt/Aging/Risk"]
H["CURRENT STATE<br/>STEP5_PHASE_B_BOOTSTRAP_ACTIVE"]
I["NEXT STEP<br/>Step-5 core hardening<br/>LLM-first validation + clarification UX"]
K["Batch-2 Gate Closed<br/>36/36 + Global PASS"]
J["Operational Rail<br/>Scheduler: Disabled<br/>Manual nightly only"]
@ -37,6 +37,10 @@ flowchart LR
`docs/ADDRESS/runs/2026-04-08_Address_Batch2_Lifecycle_FullGate_PhaseC_PostFix2/run_summary.json`
- Master checker (entry to Batch-2):
`docs/ADDRESS/address_query/step4_wave1_batch1_master_checker_v1.md`
- Step-5 plan (current focus):
`docs/ADDRESS/address_query/step5_architecture_ux_quality_plan_v1_2026-04-08.md`
- Step-5 UX smoke (bootstrap):
`docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json`
- Batch-2 Phase-A artifacts:
- `docs/ADDRESS/address_query/domain_general_batch2_lifecycle_card_v1.md`
- `docs/ADDRESS/question_sets/domain_general_batch2_lifecycle_acceptance_2026-04-08_phaseA.json`
@ -50,4 +54,14 @@ flowchart LR
- Функциональные гейты закрыты.
- Точечный flake `S019` закрыт.
- Batch-2 lifecycle закрыт до `Phase C`: domain gate `36/36`, global regression `102/102 + 25/25`, comparator PASS.
- Следующий практический шаг: стартовать scoping Batch-4/Batch-5 (`debt/aging/risk`) как отдельную доменную волну.
- Step-5 запущен: UX bootstrap-патч в `composeStage` и targeted smoke `6/6`.
- Следующий практический шаг: core hardening Step-5 (LLM-first validation + clarification UX), при domain scope freeze.
## Step-5 Increment Update (2026-04-08)
- Added `address_dialog_continuation_contract_v2` in runtime follow-up flow (`new_topic` / `continue_previous` / `switch_to_suggested`).
- Added safe retry for retryable limited answers (`limited_reason_category: missing_anchor | empty_match`) with retry on raw user message and preserved follow-up context.
- Added low-quality pseudo-anchor guard for referential follow-ups (`кроме этого ...`) with stable anchor carryover.
- Added debug/log audit fields: `dialog_continuation_contract_v2`, `address_retry_audit`.
- Targeted regression after hardening: `246/246` PASS (`assistantAddressFollowupContext`, `addressQueryRuntimeM23`, `assistantAddressLlmPredecompose`).
- Living router increment: conversational `chat` mode added for non-data messages with safe fallback to deep pipeline (`assistantLivingRouter 4/4`, `assistantLivingChatMode 1/1`, build PASS).

View File

@ -51,7 +51,7 @@ Batch-1 можно переводить в runtime только после за
- [x] Phase B.1 (Batch-1 prep): реализованы `period_coverage_profile`, `document_type_and_account_section_profile`, `counterparty_population_and_roles`, `contract_usage_overview` (unit/build green).
- [x] Phase B.2 (Batch-3 value prep): реализованы `customer_revenue_and_payments`, `supplier_payouts_profile`, `contract_usage_and_value`; стандарт ранжирования `top-20`; routing усилен для сленга/опечаток.
- [x] Targeted live-check Batch-1 next pack (`Q6/Q7/Q28`) выполнен: `strict factual 9/9`.
- [x] Targeted code gate по расширенному management/value слою: `addressQueryRuntimeM23.test.ts = 200/200`, `assistantAddressLlmPredecompose.test.ts = PASS`, `build=PASS`.
- [x] Targeted code gate по расширенному management/value слою: `addressQueryRuntimeM23.test.ts + assistantAddressFollowupContext.test.ts = 223/223`, `build=PASS`.
- [x] Phase C (Batch-1 domain pack) прогнан: `domain_general_batch1_acceptance_2026-04-02_phaseA.json` -> `strict 28/28`
run: `docs/ADDRESS/runs/2026-04-03_Address_Domain_General_B1_PhaseC_LiveGate_R3/`
- [x] Batch-3 value live-gate прогнан: `temp_batch3_value_top20_2026-04-02.json` -> `strict 33/33`
@ -59,8 +59,8 @@ Batch-1 можно переводить в runtime только после за
- [x] Global non-regression + comparator к baseline: `PASS`
run: `docs/ADDRESS/runs/2026-04-03_Address_Nightly_Regression_Post_AnchorHardening_R6/nightly_summary.json`
детали: `overall_ok=true`, `stress_102=102/102`, `followup_25=25/25`, comparator PASS.
- [x] Контрольный nightly recheck (`2026-04-08 10:51`) закрыт в `PASS`
run: `docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_10-51-20/nightly_summary.json`
- [x] Контрольный nightly recheck (`2026-04-08 13:19`) закрыт в `PASS`
run: `docs/ADDRESS/runs/2026-04-08_Address_Nightly_Regression_2026-04-08_13-19-24/nightly_summary.json`
детали: `stress_102=102/102`, `followup_25=25/25`, comparator PASS.
- [x] Batch-2 lifecycle Phase A стартован:
- `domain_general_batch2_lifecycle_card_v1.md`
@ -69,13 +69,13 @@ Batch-1 можно переводить в runtime только после за
- `step4_wave1_batch2_phaseA_backlog_v1.md`
- [x] Batch-2 Phase-B progress:
- resolver drift по `Q12/Q13/Q26/Q27/Q31/Q32` закрыт
- `addressQueryRuntimeM23.test.ts = 210/210`, `build=PASS`
- `addressQueryRuntimeM23.test.ts + assistantAddressFollowupContext.test.ts = 223/223`, `build=PASS`
- live hotpass `wave1_batch2_phaseB_resolver_hotpass_2026-04-08.md` (`route match 6/6`)
- [x] Phase B закрыт.
- [x] Phase C закрыт.
## 4. Решение на сейчас
1. Начинать можно, но строго по фазам выше.
2. Прямое включение Batch-1 intents в production-path без Phase B/C — запрещено.
3. Точка входа в работу: Batch-2 уже в `Phase A active`; следующий шаг — выполнить Batch-2 Phase B и закрыть domain gate/глобальный comparator.
1. Wave-1 domain-расширение (Batch-1/Batch-2/Batch-3) зафиксировано как закрытое.
2. Новые домены временно не запускаем (`domain scope freeze`).
3. Следующая точка входа в работу: Step-5 (`Architecture + UX + Answer Quality`), с приоритетом LLM-first валидации пользовательского запроса и качества ответа.

View File

@ -79,8 +79,9 @@ Scope: `Q8..Q13 + Q26 + Q27 + Q31 + Q32`
## 4. Текущий приоритет (следующий кодовый шаг)
1. Перевести Batch-2 из wave backlog в master checker как `closed`.
2. Подготовить Batch-4/Batch-5 scope (debt/aging/risk) отдельной доменной карточкой.
3. Держать nightly regression в green-контуре как release guardrail.
2. Ввести `domain scope freeze` на текущем объеме доменов.
3. Перейти к Step-5 (`Architecture + UX + Answer Quality`) без запуска новых доменных волн.
4. Держать nightly regression в green-контуре как release guardrail.
## 5. Фактические артефакты закрытия

View File

@ -0,0 +1,198 @@
# Step-5 Architecture + UX Quality Plan V1
Дата: 2026-04-08
Статус: `active` (Phase B bootstrap started)
Контур: `question_mode=address_query` (live-first, MCP)
## 1. Что фиксируем сейчас
1. Текущий объем доменов фиксируем (`domain scope freeze`).
2. Новые домены временно не добавляем.
3. Фокус переносим на качество архитектуры и пользовательского ответа.
## 2. Цель Step-5 (простыми словами)
Сделать ассистента менее “механическим” и более полезным:
1. меньше ручной словарной рутины;
2. больше смысловой обработки через LLM;
3. ответ по делу, в человеческом формате, с четкой конкретикой и аккуратными рекомендациями.
## 3. Принципы этапа
1. LLM отвечает за понимание смысла и валидацию формулировки пользователя.
2. Runtime отвечает за безопасность маршрута (`intent -> filters -> recipe -> MCP`).
3. LLM не генерирует свободные запросы к 1С/БД.
4. Company-specific словари/хардкод не добавляем.
## 4. Целевая схема (LLM-first, но безопасно)
1. `LLM Semantic Contract`:
- на входе формируем каноническое представление запроса;
- фиксируем `intent`, `entities`, `period`, `user_goal`, `confidence`, `ambiguity`.
2. `Route Validator`:
- проверяем, что intent поддержан и фильтры допустимы;
- при конфликте/нехватке данных выдаем корректный clarification, а не псевдо-factual.
3. `Execution`:
- только whitelist recipes и текущий MCP-контур.
4. `Answer Composer`:
- сначала прямой ответ на вопрос;
- затем короткое обоснование (по данным);
- затем опционально рекомендации (если уместно и подтверждено данными).
## 5. Рабочие пакеты
### WP-1. Уход от словарной рутины
1. Зафиксировать “словарный freeze”: новые slang-слова не добавляем как основной путь.
2. Расширять покрытие через LLM-contract + regression-набор перефразировок.
3. Ввести метрику доли запросов, обработанных через LLM canonical path без словарного патча.
### WP-2. LLM-first валидация запроса
1. Добавить обязательную проверку качества canonical-контракта:
- intent consistency;
- entity consistency;
- period consistency.
2. Если confidence низкий, задавать 1 целевой уточняющий вопрос (без “простыни”).
3. Убрать токсичный UX fallback вида “что сломано / ограничения” там, где вопрос обычный и решаемый.
### WP-3. Качество ответа для пользователя
1. Формат ответа по умолчанию:
- `короткий прямой ответ`;
- `ключевые цифры/факты`;
- `опционально: что это значит для бизнеса`.
2. Для ranking/summary ответов:
- не смешивать несколько разных вопросов в один ответ;
- явно писать период и сущность (контрагент/договор/документ).
3. Для рекомендаций:
- только мягкие и grounded (на основе текущих данных);
- без выдуманных советов “из воздуха”.
### WP-4. UX и follow-up
1. Стабилизировать follow-up цепочки (короткие реплики, местоимения, “а теперь за 21”).
2. Держать контекст предыдущего успешного address-ответа.
3. Не терять контекст после промежуточного clarification.
## 6. Gate и метрики Step-5
Обязательные:
1. не просесть ниже baseline:
- stress `102/102`;
- follow-up `25/25`;
- comparator `PASS`.
2. `false_factual_rate = 0`.
3. `execution_error_rate = 0`.
Новые UX-метрики:
1. `clarification_rate` на целевом UX-наборе снижается.
2. `direct_answer_rate` растет (ответ начинается с сути, а не с шаблонного fallback).
3. `mixed_answer_rate` (слипание ответов на разные вопросы) снижается к 0.
## 7. Фазы выполнения
1. **Phase A — Contract/UX Design**
описать v2 контракт и формат “человеческого” ответа.
2. **Phase B — Runtime Implementation**
внедрить LLM-first validation + composer improvements.
3. **Phase C — Live UX Gate**
прогон targeted UX-набора + контроль global non-regression.
## 8. Критерий завершения Step-5
Step-5 считается закрытым, когда одновременно:
1. baseline quality gates остаются зелеными;
2. на UX-наборе заметно меньше ненужных clarification/fallback ответов;
3. ответы стабильно конкретные, по вопросу, без механического шаблона;
4. новые domain-расширения можно запускать уже на более зрелой архитектуре.
## 9. Прогресс на 2026-04-08
Сделано (первый рабочий инкремент):
1. UX-ответы в `composeStage` переведены в формат “суть первой строкой”:
- для management/ranking/counterparty/document/bank intents первая строка теперь дает прямой ответ (количество/итог/top-заголовок);
- служебные строки вида “собран профиль…” оставлены как supporting block.
2. Исправлен role-focus для фраз типа `скока поставщиков в базе`:
- при явном supplier/customer/mixed-сигнале приоритет отдается роли, даже если LLM-канонизация добавила общий `контрагент` контекст.
3. Regression tests green:
- `addressQueryRuntimeM23.test.ts` -> `220/220`;
- `assistantAddressFollowupContext.test.ts` -> `3/3`.
4. Live UX smoke (targeted) green:
- набор: `docs/ADDRESS/question_sets/step5_ux_smoke_2026-04-08.json`;
- run v2: `docs/ADDRESS/runs/2026-04-08_Address_Step5_UX_Smoke_v2/run_summary.json`;
- результат: `strict 6/6`.
Наблюдение:
1. На targeted smoke ответы стали короче и конкретнее в первой строке (например: `Поставщиков ...: 79`, `Активные заказчики в 2020 году: 13`, `Найдено документов ...: 12`).
## 10. Increment 2026-04-08 (Anchor Clarification UX)
1. Hardened `partial_coverage` for anchor mismatch in `addressQueryService`.
2. If mismatch reason is `counterparty_anchor_not_matched` or `contract_anchor_not_matched`, runtime now returns targeted clarification (anchor-first), instead of generic period-first suggestion.
3. For counterparty/contract mismatch, next step keeps recognized period window and asks for exact entity anchor.
4. Added regression assertion in `addressQueryRuntimeM23.test.ts` for counterparty mismatch clarification wording.
5. Validation:
- `addressQueryRuntimeM23.test.ts` -> PASS (220/220)
- `assistantAddressFollowupContext.test.ts` -> PASS (3/3)
- `npm run build` -> PASS.
## 11. Increment 2026-04-08 (Dialog Continuation v2 + Safe Retry)
1. В `assistantService` добавлен `address_dialog_continuation_contract_v2`:
- решения `new_topic` / `continue_previous` / `switch_to_suggested`;
- явная фиксация `previous_intent`, `target_intent`, `reasons`.
2. Расширены follow-up сигналы для референсных реплик:
- формы вида `этого/этом/эту/эти...`, `кроме этого...`, `помимо этого...`, `есть еще...`.
3. Добавлен `safe retry` для address-lane:
- если первый ответ ограниченный (`limited_reason_category in missing_anchor|empty_match`) и есть риск деградации после rewrite;
- runtime делает повторный проход по raw user message с сохранением контекста предыдущего успешного ответа.
4. Для follow-up запросов типа `кроме этого документа...` добавлена защита от псевдо-якорей:
- low-quality anchor заменяется на устойчивый якорь из follow-up контекста.
5. В debug/log добавлены поля аудита:
- `dialog_continuation_contract_v2`;
- `address_retry_audit`.
Validation (targeted regression, 2026-04-08):
1. `assistantAddressFollowupContext.test.ts` -> PASS (`7/7`)
2. `addressQueryRuntimeM23.test.ts` -> PASS (`223/223`)
3. `assistantAddressLlmPredecompose.test.ts` -> PASS (`16/16`)
4. Total targeted pack -> PASS (`246/246`)
## 12. Increment 2026-04-08 (Living Assistant Router v1)
1. Added a lightweight living router in `assistantService`:
- `address_data` when address lane is triggered;
- `chat` for non-data conversational messages;
- `deep_analysis` fallback for data-like or uncertain cases.
2. Added new runtime flag:
- `FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1` (default: `true`).
3. Added live chat generation path via `OpenAIResponsesClient.chat(...)`:
- OpenAI-compatible local provider support (`/responses` with fallback to `/chat/completions`);
- safe fallback to existing deep pipeline if chat call fails.
4. Safety behavior:
- in `useMock=true` mode, router keeps deep pipeline (no network in tests);
- no changes to address rails and follow-up context logic.
5. Validation:
- `assistantLivingRouter.test.ts` -> PASS (`4/4`);
- `assistantLivingChatMode.test.ts` -> PASS (`1/1`);
- targeted regression pack with address follow-up/predecompose tests -> PASS (`28/28`);
- `npm run build` -> PASS.
## 13. Increment 2026-04-08 (ToolGate hardening for casual chat)
1. Fixed address tool gate over-trigger:
- `llm_canonical_candidate_detected` no longer auto-runs address lane when predecompose contract is `mode=unsupported` or `intent=unknown` with low confidence.
2. Result:
- short casual messages (`йо`, `привет`) no longer fall into clarification-heavy deep/accounting pipeline.
- they are handled by living chat mode.
3. Validation:
- extended `assistantLivingChatMode.test.ts` with repro-case `йо` + rewritten canonical candidate.
- PASS (`2/2`) with assertion that `addressQueryService.tryHandle` is not called.

View File

@ -0,0 +1,8 @@
[
{"id":"UX001","text":"какие клиенты самые доходные"},
{"id":"UX002","text":"скока поставщиков в базе"},
{"id":"UX003","text":"какие заказчики работали с нами в 2020 году"},
{"id":"UX004","text":"какие договоры давно не использовались"},
{"id":"UX005","text":"покажи документы по нортону"},
{"id":"UX006","text":"покажи операции по договору 19/15"}
]

View File

@ -0,0 +1,33 @@
# 2026-04-08_Address_Step5_UX_Smoke_v1
Generated at: 2026-04-08T13:53:34
Questions file: X:\1C\NDC_1C\docs\ADDRESS\question_sets\step5_ux_smoke_2026-04-08.json
Backend URL: http://127.0.0.1:8787/api/assistant/message
LLM: local / qwen2.5-14b-instruct-1m @ http://127.0.0.1:1234
Strict policy: route
## Totals
- questions_total: 6
- ok_200_count: 6
- semantic_pass_count: 6
- semantic_pass_rate: 1.0
- route_pass_count: 6
- route_pass_rate: 1.0
- strict_pass_count: 6
- strict_pass_rate: 1.0
- factual_count: 6
- partial_coverage_count: 0
- clarification_required_count: 0
- http_error_count: 0
- llm_decomposition_attempted_count: 6
- llm_decomposition_applied_count: 4
- llm_fallback_count: 0
- llm_fallback_rate: 0.0
- tool_gate_blocked_count: 0
- tool_gate_blocked_rate: 0.0
- avg_elapsed_ms: 5562.2
## Files
- run_summary.json
- full_live_results.json
- failures_only.json

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
# Response Audit: 2026-04-08_Address_Step5_UX_Smoke_v1
| id | strict | route_health | reply_type | intent | limited_reason | question | assistant_first_line |
|---|---|---|---|---|---|---|---|
| UX001 | True | ok_or_factual | factual | customer_revenue_and_payments | None | какие клиенты самые доходные | Топ-5 заказчиков по сумме поступлений: |
| UX002 | True | ok_or_factual | factual | counterparty_population_and_roles | None | скока поставщиков в базе | Всего уникальных контрагентов в базе: 139. |
| UX003 | True | ok_or_factual | factual | counterparty_activity_lifecycle | None | какие заказчики работали с нами в 2020 году | Активные заказчики в 2020 году: 13. |
| UX004 | True | ok_or_factual | factual | contract_usage_overview | None | какие договоры давно не использовались | Использованных договоров: 291 из 394 (73.9%). |
| UX005 | True | ok_or_factual | factual | list_documents_by_counterparty | None | покажи документы по нортону | Найдено документов по контрагенту: 12. |
| UX006 | True | ok_or_factual | factual | bank_operations_by_contract | None | покажи операции по договору 19/15 | Найдено банковских операций по договору: 1. |

View File

@ -0,0 +1,126 @@
{
"run_id": "2026-04-08_Address_Step5_UX_Smoke_v1",
"generated_at": "2026-04-08T13:53:34",
"source_questions_file": "X:\\1C\\NDC_1C\\docs\\ADDRESS\\question_sets\\step5_ux_smoke_2026-04-08.json",
"backend_url": "http://127.0.0.1:8787/api/assistant/message",
"llm_provider": "local",
"llm_model": "qwen2.5-14b-instruct-1m",
"llm_base_url": "http://127.0.0.1:1234",
"strict_policy": "route",
"totals": {
"questions_total": 6,
"ok_200_count": 6,
"semantic_pass_count": 6,
"semantic_pass_rate": 1.0,
"route_pass_count": 6,
"route_pass_rate": 1.0,
"strict_pass_count": 6,
"strict_pass_rate": 1.0,
"factual_count": 6,
"partial_coverage_count": 0,
"clarification_required_count": 0,
"http_error_count": 0,
"llm_decomposition_attempted_count": 6,
"llm_decomposition_applied_count": 4,
"llm_fallback_count": 0,
"llm_fallback_rate": 0.0,
"tool_gate_blocked_count": 0,
"tool_gate_blocked_rate": 0.0,
"avg_elapsed_ms": 5562.2
},
"distributions": {
"reply_type": {
"factual": 6
},
"actual_intent": {
"customer_revenue_and_payments": 1,
"counterparty_population_and_roles": 1,
"counterparty_activity_lifecycle": 1,
"contract_usage_overview": 1,
"list_documents_by_counterparty": 1,
"bank_operations_by_contract": 1
},
"actual_mode": {
"address_query": 6
},
"mcp_call_status": {
"matched_non_empty": 6
},
"limited_reason_category": {},
"route_health": {
"ok_or_factual": 6
},
"tool_gate_decision": {
"run_address_lane": 6
},
"tool_gate_reason": {
"address_mode_classifier_detected": 6
}
},
"address_llm_predecompose_metrics": {
"overall": {
"llm_attempted": 6,
"llm_applied": 4,
"fallback_used": 0,
"fallback_rate": 0.0,
"tool_gate_blocked": 0,
"gate_block_rate": 0.0
},
"by_intent": {
"customer_revenue_and_payments": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 0,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"counterparty_population_and_roles": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"counterparty_activity_lifecycle": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"contract_usage_overview": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 0,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"list_documents_by_counterparty": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"bank_operations_by_contract": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
}
}
}
}

View File

@ -0,0 +1,33 @@
# 2026-04-08_Address_Step5_UX_Smoke_v2
Generated at: 2026-04-08T13:55:47
Questions file: X:\1C\NDC_1C\docs\ADDRESS\question_sets\step5_ux_smoke_2026-04-08.json
Backend URL: http://127.0.0.1:8787/api/assistant/message
LLM: local / qwen2.5-14b-instruct-1m @ http://127.0.0.1:1234
Strict policy: route
## Totals
- questions_total: 6
- ok_200_count: 6
- semantic_pass_count: 6
- semantic_pass_rate: 1.0
- route_pass_count: 6
- route_pass_rate: 1.0
- strict_pass_count: 6
- strict_pass_rate: 1.0
- factual_count: 6
- partial_coverage_count: 0
- clarification_required_count: 0
- http_error_count: 0
- llm_decomposition_attempted_count: 6
- llm_decomposition_applied_count: 4
- llm_fallback_count: 0
- llm_fallback_rate: 0.0
- tool_gate_blocked_count: 0
- tool_gate_blocked_rate: 0.0
- avg_elapsed_ms: 5551.5
## Files
- run_summary.json
- full_live_results.json
- failures_only.json

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
# Response Audit: 2026-04-08_Address_Step5_UX_Smoke_v2
| id | strict | route_health | reply_type | intent | limited_reason | question | assistant_first_line |
|---|---|---|---|---|---|---|---|
| UX001 | True | ok_or_factual | factual | customer_revenue_and_payments | None | какие клиенты самые доходные | Топ-5 заказчиков по сумме поступлений: |
| UX002 | True | ok_or_factual | factual | counterparty_population_and_roles | None | скока поставщиков в базе | Поставщиков (только supplier-роль): 79. |
| UX003 | True | ok_or_factual | factual | counterparty_activity_lifecycle | None | какие заказчики работали с нами в 2020 году | Активные заказчики в 2020 году: 13. |
| UX004 | True | ok_or_factual | factual | contract_usage_overview | None | какие договоры давно не использовались | Использованных договоров: 291 из 394 (73.9%). |
| UX005 | True | ok_or_factual | factual | list_documents_by_counterparty | None | покажи документы по нортону | Найдено документов по контрагенту: 12. |
| UX006 | True | ok_or_factual | factual | bank_operations_by_contract | None | покажи операции по договору 19/15 | Найдено банковских операций по договору: 1. |

View File

@ -0,0 +1,126 @@
{
"run_id": "2026-04-08_Address_Step5_UX_Smoke_v2",
"generated_at": "2026-04-08T13:55:47",
"source_questions_file": "X:\\1C\\NDC_1C\\docs\\ADDRESS\\question_sets\\step5_ux_smoke_2026-04-08.json",
"backend_url": "http://127.0.0.1:8787/api/assistant/message",
"llm_provider": "local",
"llm_model": "qwen2.5-14b-instruct-1m",
"llm_base_url": "http://127.0.0.1:1234",
"strict_policy": "route",
"totals": {
"questions_total": 6,
"ok_200_count": 6,
"semantic_pass_count": 6,
"semantic_pass_rate": 1.0,
"route_pass_count": 6,
"route_pass_rate": 1.0,
"strict_pass_count": 6,
"strict_pass_rate": 1.0,
"factual_count": 6,
"partial_coverage_count": 0,
"clarification_required_count": 0,
"http_error_count": 0,
"llm_decomposition_attempted_count": 6,
"llm_decomposition_applied_count": 4,
"llm_fallback_count": 0,
"llm_fallback_rate": 0.0,
"tool_gate_blocked_count": 0,
"tool_gate_blocked_rate": 0.0,
"avg_elapsed_ms": 5551.5
},
"distributions": {
"reply_type": {
"factual": 6
},
"actual_intent": {
"customer_revenue_and_payments": 1,
"counterparty_population_and_roles": 1,
"counterparty_activity_lifecycle": 1,
"contract_usage_overview": 1,
"list_documents_by_counterparty": 1,
"bank_operations_by_contract": 1
},
"actual_mode": {
"address_query": 6
},
"mcp_call_status": {
"matched_non_empty": 6
},
"limited_reason_category": {},
"route_health": {
"ok_or_factual": 6
},
"tool_gate_decision": {
"run_address_lane": 6
},
"tool_gate_reason": {
"address_mode_classifier_detected": 6
}
},
"address_llm_predecompose_metrics": {
"overall": {
"llm_attempted": 6,
"llm_applied": 4,
"fallback_used": 0,
"fallback_rate": 0.0,
"tool_gate_blocked": 0,
"gate_block_rate": 0.0
},
"by_intent": {
"customer_revenue_and_payments": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 0,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"counterparty_population_and_roles": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"counterparty_activity_lifecycle": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"contract_usage_overview": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 0,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"list_documents_by_counterparty": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
},
"bank_operations_by_contract": {
"total": 1,
"llm_attempted": 1,
"llm_applied": 1,
"fallback_used": 0,
"tool_gate_blocked": 0,
"fallback_rate": 0.0,
"gate_block_rate": 0.0
}
}
}
}

View File

@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0;
const path_1 = __importDefault(require("path"));
exports.BACKEND_ROOT = path_1.default.resolve(__dirname, "..");
exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, "..");
@ -48,6 +48,7 @@ exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = toBooleanFlag(process.env.FEATURE_ASS
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_V1, true);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1, true);
exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1, true);
exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1, true);
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);

View File

@ -8,7 +8,7 @@ const iconv_lite_1 = __importDefault(require("iconv-lite"));
const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[.,]\d{1,2})?)/i;
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-—_:№#]*?(\d{1,3})/iu;
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const CONTRACT_PATTERN = /(?:по\s+(?:договору|контракту)|(?:договор|контракт)(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
@ -298,47 +298,46 @@ function extractYearRangePeriod(text) {
};
}
function cleanupAnchorValue(value) {
const normalized = String(value ?? "").trim();
if (!normalized) {
const stripOuterQuotes = (text) => String(text ?? "")
.replace(/^['"«»“”„`]+|['"«»“”„`]+$/gu, "")
.trim();
let cleaned = stripOuterQuotes(String(value ?? "").trim());
if (!cleaned) {
return "";
}
// Remove trailing as-of qualifiers often captured by broad contract/counterparty regexes:
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
const asOfTailPattern = /\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
if (asOfTailPattern.test(normalized)) {
return normalized.replace(asOfTailPattern, "").trim();
}
const asOfTruncatedTailPattern = /\s+на\s+дат[ауеы]\s+\d{1,2}(?:\s+|$)[\s\S]*$/iu;
if (asOfTruncatedTailPattern.test(normalized)) {
return normalized.replace(asOfTruncatedTailPattern, "").trim();
}
const asOfReportDateTailPattern = /\s+на\s+дат[ауеы]\s+(?:отчетност[ьи]|отч[её]тн(?:ую|ой)?\s+дат[ауеы]|конец(?:\s+период[а-яё]*)?)\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?(?:\s+|$)[\s\S]*$/iu;
const periodEndTailPattern = /\s+на\s+конец(?:\s+период[а-яё]*)?\s+(?:\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|\d{4}|[a-zа-яё]+\s+\d{4})(?:\s+|$)[\s\S]*$/iu;
if (periodEndTailPattern.test(normalized)) {
return normalized.replace(periodEndTailPattern, "").trim();
}
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// "<counterparty> с 2020-07-01 по 2020-07-31", "<counterparty> from 2020-07-01 to 2020-07-31"
const periodTailPattern = /\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
if (periodTailPattern.test(normalized)) {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern = /\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPattern.test(normalized)) {
return normalized.replace(allTimeTailPattern, "").trim();
}
const allTimeTailPatternEn = /\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
for (const tailPattern of [
asOfTailPattern,
asOfTruncatedTailPattern,
asOfReportDateTailPattern,
periodEndTailPattern,
periodTailPattern,
allTimeTailPattern,
allTimeTailPatternEn
]) {
if (tailPattern.test(cleaned)) {
cleaned = stripOuterQuotes(cleaned.replace(tailPattern, "").trim());
}
}
const trailingYearTailPattern = /\s+(?:year\s+)?(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?:\s+|$)[\s\S]*$/iu;
let cleaned = normalized;
if (trailingYearTailPattern.test(normalized)) {
cleaned = normalized.replace(trailingYearTailPattern, "").trim();
if (trailingYearTailPattern.test(cleaned)) {
cleaned = stripOuterQuotes(cleaned.replace(trailingYearTailPattern, "").trim());
}
return cleaned
cleaned = cleaned
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
return stripOuterQuotes(cleaned);
}
function cleanupContractAnchorValue(value) {
let normalized = cleanupAnchorValue(value);
@ -395,6 +394,8 @@ function extractLooseByAnchorValue(text) {
"партнера",
"договору",
"договора",
"контракту",
"контракта",
"счету",
"счёту",
"дате",
@ -442,6 +443,7 @@ function extractLooseByAnchorValue(text) {
"linked",
"нему",
"ней",
"нее",
"ним",
"этому",
"тому",
@ -502,10 +504,38 @@ function isLikelyCounterpartyToken(rawToken) {
"каких",
"какому",
"какую",
"кто",
"что",
"чего",
"где",
"когда",
"почему",
"зачем",
"сколько",
"чьи",
"чья",
"чей",
"чью",
"самый",
"самая",
"самое",
"самые",
"крупный",
"крупная",
"крупное",
"крупные",
"жирный",
"жирная",
"жирное",
"жирные",
"больше",
"меньше",
"платит",
"платят",
"денег",
"деньги",
"объем",
"объём",
"док",
"доки",
"документ",
@ -634,6 +664,13 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
if (tokens.length === 0) {
return true;
}
const questionCue = /(?:кто|что|какой|какая|какие|какого|сколько|где|когда|почему|зачем|which|who|what|how\s+many)/iu.test(value) ||
/[?]/u.test(String(rawValue ?? ""));
const rankingCue = /(?:больше|меньше|сам(?:ый|ая|ое|ые)|крупн|жирн|максим|миним)/iu.test(value);
const paymentCue = /(?:плат(?:ит|ят|еж|ёж|ежн|ежей|ежа)|денег|деньг|money|payment)/iu.test(value);
if (questionCue && (rankingCue || paymentCue)) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0;
}
@ -671,7 +708,9 @@ function isLowQualityContractAnchorValue(rawValue) {
"период",
"периоду",
"договор",
"договору"
"договору",
"контракт",
"контракту"
]);
const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;

View File

@ -54,7 +54,8 @@ const OPEN_CONTRACTS_HINTS = [
"незакрыт",
"не закрыт",
"открыт",
"договор"
"договор",
"контракт"
];
const OPEN_ITEMS_HINTS = [
"open items",
@ -124,7 +125,10 @@ const DOCUMENTS_BY_CONTRACT_HINTS = [
"доки по договору",
"док по договору",
"документы договор",
"договор"
"договор",
"документы по контракту",
"доки по контракту",
"контракт"
];
const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank operations by contract",
@ -134,7 +138,10 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank ops by contract",
"банковские операции по договору",
"платежи по договору",
"выписка по договору"
"выписка по договору",
"банковские операции по контракту",
"платежи по контракту",
"выписка по контракту"
];
const BANK_OPERATION_CORE_HINTS = [
"банк",
@ -257,6 +264,14 @@ const COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS = [
"регулярные поставщики",
"эпизодические поставщики",
"давно не использовались поставщики",
"всех заков",
"кто был активен",
"потом отвалился",
"ровно один раз",
"и пропал",
"самые старые по сотрудничеству",
"разбей поставщиков на регуляр и разовые",
"кто новые в этом году",
"active customers",
"customer activity list",
"counterparty lifecycle"
@ -314,14 +329,22 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры по обороту",
"договоры по сумме оборота",
"топ договоров по обороту",
"контракты по обороту",
"контракты по сумме оборота",
"топ контрактов по обороту",
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
"контракты с минимальным бюджетом",
"контракты с самым маленьким бюджетом",
"активные договоры по бюджету",
"активные контракты по бюджету",
"контрагенты с несколькими договорами",
"несколько договоров у контрагента",
"мультидоговорные контрагенты",
"какие договоры активны",
"какие контракты активны",
"рабочие договоры",
"рабочие контракты",
"contracts by turnover",
"contracts by budget"
];
@ -331,6 +354,10 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
"список договоров по",
"покажи договоры по",
"выведи договоры по",
"контракты по",
"список контрактов по",
"покажи контракты по",
"выведи контракты по",
"contracts by counterparty",
"list contracts by counterparty",
"show contracts by counterparty"
@ -550,10 +577,10 @@ function hasCounterpartyPopulationAndRolesSignal(text) {
return false;
}
function hasLifecycleSegmentationSignal(text) {
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|только\s+один\s+раз|однораз|дольше\s+всех|долгожив|регулярн|эпизодич|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text);
return /(?:вперв|нов(?:ые|ых|ые\s+контрагент|ые\s+клиент|ые\s+заказчик)|исчез|ушед|ушл|пропал|отвал|только\s+один\s+раз|ровно\s+один\s+раз|однораз|дольше\s+всех|долгожив|самые\s+старые|старые\s+по\s+сотрудничеству|регуляр|эпизодич|разов(?:ые|ой|ые\s+поставщик)|давно\s+не\s+использ|неиспольз|потом\s+перестал)/iu.test(text);
}
function hasCounterpartyActivityLifecycleSignal(text) {
if (hasDocumentSignal(text) || hasBankOperationSignal(text)) {
if ((hasDocumentSignal(text) || hasBankOperationSignal(text)) && !hasLifecycleSegmentationSignal(text)) {
return false;
}
if (hasAny(text, COUNTERPARTY_ACTIVITY_LIFECYCLE_HINTS)) {
@ -567,6 +594,7 @@ function hasCounterpartyActivityLifecycleSignal(text) {
const hasTimeWindowLexeme = /(?:за\s+вс[её]\s+время|all\s+time|\b(?:19|20)\d{2}\b|(?:^|[^\d])\d{2}\s*(?:г(?:од|ода)?|г)(?:[^\p{L}\p{N}]|$)|в\s+конкретн(?:ом|ый)\s+год|за\s+год|в\s+году)/iu.test(text);
const hasListVerb = /(?:какие|кто|покажи|выведи|список|list|show)/iu.test(text);
const hasRosterQualifier = /(?:у\s+нас|вообще|в\s+баз[еы]|какие\s+есть|кто\s+есть|who\s+are)/iu.test(text);
const hasImplicitCounterpartyQuestion = /(?:кто\s+с\s+нами|кто\s+у\s+нас|всех?\s+зак(?:ов|а|и)?|все\s+заки|кто\s+нов(?:ые|ых|ый)\b|кто\s+был\s+активен|самые\s+старые\s+по\s+сотрудничеству)/iu.test(text);
const hasListWithWindow = hasCounterpartyLexeme && hasListVerb && hasTimeWindowLexeme;
if (hasListWithWindow) {
return true;
@ -577,22 +605,28 @@ function hasCounterpartyActivityLifecycleSignal(text) {
if (hasCounterpartyLexeme && hasLifecycleSegmentationSignal(text)) {
return true;
}
if (hasImplicitCounterpartyQuestion && (hasLifecycleSegmentationSignal(text) || hasTimeWindowLexeme || hasActivityLexeme)) {
return true;
}
if (!hasCounterpartyLexeme && hasListVerb && hasLifecycleSegmentationSignal(text) && /\bкто\b/iu.test(text)) {
return true;
}
return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb);
}
function hasContractUsageOverviewSignal(text) {
if (hasAny(text, CONTRACT_USAGE_OVERVIEW_HINTS)) {
return true;
}
if (/(?:сколько\s+(?:всего\s+)?договор(?:ов|а)?(?:\s+заведен[оы])?|договорн(?:ая|ой)\s+баз[аы]).*(?:сколько|used|использ)/iu.test(text)) {
if (/(?:сколько\s+(?:всего\s+)?(?:договор|контракт)(?:ов|а)?(?:\s+заведен[оы])?|(?:договорн(?:ая|ой)|контрактн(?:ая|ой))\s+баз[аы]).*(?:сколько|used|использ)/iu.test(text)) {
return true;
}
if (/(?:сколько\s+из\s+договор(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)) {
if (/(?:сколько\s+из\s+(?:договор|контракт)(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)) {
return true;
}
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|контракт|contract)?/iu.test(text)) {
return true;
}
if (/(?:какие\s+договор(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
if (/(?:какие\s+(?:договор|контракт)(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
return true;
}
return false;
@ -606,17 +640,19 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
}
const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]);
const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]);
const hasCounterpartyLexeme = /(?:контрагент(?:ов|а|ы)?|counterpart(?:y|ies)|компан(?:и|ия|ии|ию)|организац(?:и|ия|ии|ию)|partner(?:s)?)/iu.test(text);
const hasSpecificCounterpartyAnchor = hasLooseByAnchorMention(text) ||
hasHeuristicCounterpartyAnchor(text) ||
/(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text);
const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:ит|ят)?)/iu.test(text);
const asksCustomerGroup = /(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) ||
hasFuzzyCustomerLexeme ||
/(?:кто\s+нам\s+(?:больше|чаще)|кто\s+платит)/iu.test(text);
asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksDealBudgetRanking = /(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(text);
const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(text);
const asksValue = /(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(text);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(text);
const asksCountOnly = /(?:сколько|скока|скок)\s+/iu.test(text) && !asksValue;
if (asksCountOnly) {
@ -628,6 +664,12 @@ function hasCustomerRevenueAndPaymentsSignal(text) {
if (asksCustomerGroup && (asksValue || asksRankOrTop)) {
return true;
}
if (!hasFuzzySupplierLexeme && hasCounterpartyLexeme && asksRankOrTop && (asksValue || asksWhoPays)) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (asksCounterpartySource && asksValue) {
return true;
}
@ -668,19 +710,19 @@ function hasContractUsageAndValueSignal(text) {
if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) {
return true;
}
if (!/(?:договор(?:ов|а|ы)?|contract(?:s)?)/iu.test(text)) {
if (!/(?:договор(?:ов|а|ы)?|контракт(?:ов|а|ы|у|ом|е)?|contract(?:s)?)/iu.test(text)) {
return false;
}
if (hasContractUsageOverviewSignal(text)) {
return false;
}
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+договор|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+договор|какие\s+договор(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+договор)/iu.test(text);
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+(?:договор|контракт)|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+(?:договор|контракт)|какие\s+(?:договор|контракт)(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+(?:договор|контракт))/iu.test(text);
const asksValue = /(?:оборот|бюджет|сумм|стоим|value|turnover|amount|revenue|крупн|мелк|миним|максим)/iu.test(text);
const asksRank = /(?:топ|top|ранк|rank|сам(?:ый|ая|ое|ые))/iu.test(text);
return asksStructure || asksValue || asksRank;
}
function hasContractListByCounterpartySignal(text) {
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|контракт(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
if (!hasContractLexeme) {
return false;
}
@ -715,7 +757,7 @@ function hasDocumentsByAccountDrilldownSignal(text) {
return hasAccountLexeme && hasDocLexeme && (hasDrilldownVerb || hasSameDate);
}
function hasOpenContractsListSignal(text) {
const hasContractLexeme = text.includes("договор") || text.includes("contract") || text.includes("dogovor");
const hasContractLexeme = text.includes("договор") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor");
const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text);
if (!hasContractLexeme || !hasOpenLexeme) {
return false;
@ -1059,7 +1101,7 @@ function resolveAddressIntent(userMessage) {
};
}
if (hasAny(text, OPEN_ITEMS_HINTS) &&
(text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
(text.includes("контраг") || text.includes("договор") || text.includes("контракт") || text.includes("counterparty") || text.includes("contract"))) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "medium",
@ -1192,7 +1234,7 @@ function resolveAddressIntent(userMessage) {
reasons: ["generic_lookup_with_loose_anchor_fallback"]
};
}
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) {
return {
intent: "list_open_contracts",
confidence: "medium",

View File

@ -21,6 +21,10 @@ const ADDRESS_ACTION_TOKENS = [
"вывед",
"кто",
"кому",
"какой",
"какая",
"какое",
"какую",
"какие",
"каких",
"что по",
@ -67,6 +71,7 @@ const ADDRESS_ENTITY_TOKENS = [
"клиент",
"покупател",
"партнер",
"контракт",
"банк",
"выписк",
"операц",
@ -229,7 +234,17 @@ function hasLooseByAnchorMention(text) {
"активности",
"пассивности",
"наименее",
"минимум"
"минимум",
"запрос",
"запросу",
"запроса",
"запросом",
"запросе",
"вопрос",
"вопросу",
"вопроса",
"вопросом",
"вопросе"
]);
return !stopWords.has(token);
}
@ -306,7 +321,17 @@ function hasLikelyCounterpartyToken(text) {
"пассивный",
"наименее",
"минимум",
"реже"
"реже",
"запрос",
"запросу",
"запроса",
"запросом",
"запросе",
"вопрос",
"вопросу",
"вопроса",
"вопросом",
"вопросе"
]);
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
@ -366,11 +391,11 @@ function detectAddressQuestionMode(userMessage) {
reasons: ["loose_by_anchor_detected", ...(hasFollowupSignal ? ["address_followup_signal_detected"] : [])]
};
}
if ((hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
if (hasAccountCode && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["address_entity_detected"]
reasons: ["account_code_detected"]
};
}
if (!hasDeepReasoning && hasDocsOrBankSignal(text) && (hasLooseByAnchor || hasLikelyCounterpartyToken(text))) {

View File

@ -1358,12 +1358,15 @@ class AddressQueryService {
rowsMatched: historicalFilteredRows.length
});
const historicalFactual = (0, composeStage_1.composeFactualReply)(intent.intent, historicalFilteredRows, { userMessage });
const historicalPrefix = "В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
const historicalSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
: "";
const historicalLimitations = [...filters.warnings, "historical_window_sort_recovery_applied"];
const historicalReasons = [...baseReasons, "historical_window_sort_recovery_applied"];
return {
handled: true,
reply_text: `${historicalPrefix}\n${historicalFactual.text}`,
reply_text: `${historicalPrefix}\n${historicalFactual.text}${historicalSuggestion}`,
reply_type: (0, composeStage_1.inferReplyType)(historicalFactual.responseType),
response_type: historicalFactual.responseType,
debug: {
@ -1419,12 +1422,15 @@ class AddressQueryService {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, { userMessage });
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
: "";
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
handled: true,
reply_text: "Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
fallbackFactual.text,
reply_text: `${fallbackPrefix}\n${fallbackFactual.text}${fallbackSuggestion}`,
reply_type: (0, composeStage_1.inferReplyType)(fallbackFactual.responseType),
response_type: fallbackFactual.responseType,
debug: {
@ -1484,9 +1490,20 @@ class AddressQueryService {
const isFollowupAnchorCarryover = Array.isArray(filters.warnings) &&
(filters.warnings.includes("counterparty_from_followup_context") ||
filters.warnings.includes("contract_from_followup_context"));
const anchorMismatchByCounterparty = isAnchorMismatch && String(matchFailureReason ?? "").includes("counterparty_anchor_not_matched");
const anchorMismatchByContract = isAnchorMismatch && String(matchFailureReason ?? "").includes("contract_anchor_not_matched");
const isLowQualityPartyAnchor = (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
const anchorMismatchCategory = isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
const requestedPeriodFrom = typeof filters.extracted_filters.period_from === "string" ? filters.extracted_filters.period_from : null;
const requestedPeriodTo = typeof filters.extracted_filters.period_to === "string" ? filters.extracted_filters.period_to : null;
const requestedPeriodHint = requestedPeriodFrom && requestedPeriodTo ? ` (период ${requestedPeriodFrom}..${requestedPeriodTo} сохранен)` : "";
const anchorMismatchCategory = isFollowupAnchorCarryover
? "empty_match"
: anchorMismatchByCounterparty || anchorMismatchByContract
? "missing_anchor"
: !isLowQualityPartyAnchor
? "empty_match"
: "missing_anchor";
const category = isAnchorMismatch
? anchorMismatchCategory
: isRecipeFilteredOut
@ -1495,18 +1512,26 @@ class AddressQueryService {
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в материализованных live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
? anchorMismatchByCounterparty
? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
: anchorMismatchByContract
? "договор по указанному номеру/названию не найден в materialized live-строках"
: anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в materialized live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
? anchorMismatchByCounterparty
? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
: anchorMismatchByContract
? `уточните номер/наименование договора${requestedPeriodHint}`
: anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
@ -1514,9 +1539,13 @@ class AddressQueryService {
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? [
anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
anchorMismatchByCounterparty
? "counterparty_anchor_not_matched_after_materialization"
: anchorMismatchByContract
? "contract_anchor_not_matched_after_materialization"
: anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]

View File

@ -169,13 +169,13 @@ function detectCounterpartyProfileFocus(userMessage) {
const hasCustomerToken = /(?:заказчик(?:ов|а)?|клиент(?:ов|а)?|customer(?:s)?|client(?:s)?)/iu.test(text);
const hasMixedToken = /(?:смешан|проч(?:их|ие)|mixed)/iu.test(text);
const asksRoles = /(?:заказчик(?:ов|а)?|поставщик(?:ов|а)?|смешан|проч(?:их|ие)|типы?\s+контрагент|разбей|раздели|roles?|split)/iu.test(text);
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken && !asksTotal) {
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken) {
return "suppliers_only";
}
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken) {
return "customers_only";
}
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken) {
return "mixed_only";
}
if (asksTotal && !asksRoles) {
@ -614,7 +614,17 @@ function composeFactualReply(intent, rows, options = {}) {
const focus = detectCounterpartyProfileFocus(options.userMessage);
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
const directLead = focus === "suppliers_only"
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
: focus === "customers_only"
? `Заказчиков (только customer-роль): ${customerOnly}.`
: focus === "mixed_only"
? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
: includeTotal && totalCounterparties > 0
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
: `Активных контрагентов по операциям: ${activeCounterparties}.`;
const lines = [
directLead,
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
@ -693,17 +703,17 @@ function composeFactualReply(intent, rows, options = {}) {
? `в ${requestedYear} году`
: "в выбранном периоде";
const lines = [
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) {
lines.push("Активных заказчиков по выбранному окну не найдено.");
lines.push("По выбранному окну активности заказчики не найдены.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
lines.push(`Активные заказчики ${scopeLabel}: ${counterparties.length}.`);
const visible = counterparties.slice(0, 120);
lines.push(...visible.map((item, index) => {
const suffix = item.lastPeriod ? ` | последняя активность: ${item.lastPeriod}` : "";
@ -734,7 +744,11 @@ function composeFactualReply(intent, rows, options = {}) {
const usedContracts = sumMarker("CT_USED");
const unusedContracts = totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
const usageLead = totalContracts > 0
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
: `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
const lines = [
usageLead,
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
@ -830,9 +844,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`);
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`));
return {
responseType: "FACTUAL_LIST",
@ -841,9 +856,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_max_single") {
const visible = rankedByMaxSingle.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`);
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`));
return {
responseType: "FACTUAL_LIST",
@ -852,9 +868,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_avg_check_min_ops") {
const visible = rankedByAvgCheck.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`);
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
lines.unshift(heading);
if (visible.length === 0) {
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
}
@ -868,9 +885,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_deals") {
const visible = rankedDealsTop.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`);
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`));
return {
responseType: "FACTUAL_LIST",
@ -879,9 +897,10 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "bottom_deals") {
const visible = rankedDealsBottom.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`);
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
lines.unshift(heading);
if (activeOnlyForBottomDeals) {
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
}
@ -892,9 +911,10 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
const visible = rankedByTotal.slice(0, limit);
lines.push(isSupplier
const heading = isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`);
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
return `${index + 1}. ${item.name} | сумма: ${item.total} | операций: ${item.ops} | средний чек: ${avgCheck} | макс: ${item.maxSingle}`;
@ -943,9 +963,10 @@ function composeFactualReply(intent, rows, options = {}) {
.filter((item) => item.docs > 0 && item.turnover > 0)
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const lines = [
`Активных договоров: ${contractRows.length}.`,
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
`Строк источника: ${rows.length}.`,
`Активных договоров: ${contractRows.length}.`
`Договорных агрегатов: ${contractRows.length}.`
];
if (contractRows.length === 0) {
lines.push("В выбранном окне не найдено операций, связанных с договорами.");
@ -956,7 +977,8 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "top_by_docs") {
const visible = rankedByDocs.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по количеству операций:`);
const heading = `Топ-${visible.length} договоров по количеству операций:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | операций: ${item.docs} | оборот: ${item.turnover} | контрагентов: ${item.counterparties.size}`));
return {
responseType: "FACTUAL_LIST",
@ -965,7 +987,8 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (focus === "bottom_by_turnover_active") {
const visible = rankedBottomActive.slice(0, limit);
lines.push(`Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`);
const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
@ -973,7 +996,8 @@ function composeFactualReply(intent, rows, options = {}) {
};
}
const visible = rankedByTurnover.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.contract} | оборот: ${item.turnover} | операций: ${item.docs} | контрагентов: ${item.counterparties.size} | последняя активность: ${item.lastPeriod ?? "n/a"}`));
return {
responseType: "FACTUAL_LIST",
@ -1073,8 +1097,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
`Найдено документов по контрагенту: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
@ -1084,6 +1107,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "list_documents_by_contract") {
const lines = [
`Найдено документов по договору: ${rows.length}.`,
"Собран список документов по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@ -1095,6 +1119,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "bank_operations_by_counterparty") {
const lines = [
`Найдено банковских операций по контрагенту: ${rows.length}.`,
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@ -1106,6 +1131,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
if (intent === "bank_operations_by_contract") {
const lines = [
`Найдено банковских операций по договору: ${rows.length}.`,
"Собран список банковских операций по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)

View File

@ -24,6 +24,9 @@ function hasAllTimeHint(text) {
function hasSameDateHint(text) {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(String(text ?? ""));
}
function hasExplicitPeriodLiteral(text) {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? ""));
}
function hasOpenItemsHint(text) {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
}
@ -93,6 +96,29 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"что",
"все",
"всё",
"кроме",
"помимо",
"этого",
"этот",
"эта",
"эту",
"этом",
"это",
"эти",
"этих",
"документ",
"документа",
"документы",
"документов",
"договор",
"договора",
"контрагент",
"контрагента",
"еще",
"ещё",
"другие",
"другое",
"остальное",
"год",
"года",
"году",
@ -313,6 +339,17 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
return { filters: merged, reasons };
}
if (intent === "counterparty_activity_lifecycle" &&
hasAddressFollowupContextSignal(userMessage) &&
!hasExplicitPeriodLiteral(userMessage)) {
const currentPeriodFrom = toNonEmptyString(merged.period_from);
const currentPeriodTo = toNonEmptyString(merged.period_to);
const todayIso = new Date().toISOString().slice(0, 10);
if (!currentPeriodFrom && currentPeriodTo === todayIso) {
delete merged.period_to;
reasons.push("period_to_cleared_for_lifecycle_followup");
}
}
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
@ -439,23 +476,29 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
}
function runAddressDecomposeStage(userMessage, followupContext) {
const detectedMode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
const mode = detectedMode.mode === "address_query"
? detectedMode
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const shape = (0, addressQueryShapeClassifier_1.classifyAddressQueryShape)(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
return null;
}
const detectedIntent = (0, addressIntentResolver_1.resolveAddressIntent)(userMessage);
const mode = detectedMode.mode === "address_query"
? detectedMode
: detectedIntent.intent !== "unknown"
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_resolved_intent"]
}
: followupContext && hasAddressFollowupContextSignal(userMessage)
? {
mode: "address_query",
confidence: "medium",
reasons: [...detectedMode.reasons, "address_mode_from_followup_context"]
}
: detectedMode;
if (mode.mode !== "address_query") {
return null;
}
const intent = deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext);
const extractedFilters = (0, addressFilterExtractor_1.extractAddressFilters)(userMessage, intent.intent);
const followupMerged = mergeFollowupFilters(extractedFilters.extracted_filters, intent.intent, userMessage, followupContext);

File diff suppressed because it is too large Load Diff

View File

@ -167,6 +167,90 @@ function buildBaseUrlCandidates(config) {
return Array.from(new Set([base, `${base}/v1`]));
}
class OpenAIResponsesClient {
async chat(config, prompt) {
const responsesPayload = {
model: config.model,
temperature: prompt.temperature ?? config.temperature ?? 0.2,
max_output_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
input: [
...(String(prompt.systemPrompt ?? "").trim().length > 0
? [
{
role: "system",
content: [{ type: "input_text", text: String(prompt.systemPrompt ?? "").trim() }]
}
]
: []),
...(String(prompt.developerPrompt ?? "").trim().length > 0
? [
{
role: "developer",
content: [{ type: "input_text", text: String(prompt.developerPrompt ?? "").trim() }]
}
]
: []),
{
role: "user",
content: [{ type: "input_text", text: String(prompt.userMessage ?? "") }]
}
]
};
const provider = resolveProvider(config);
if (provider === "openai") {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
try {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
catch (error) {
if (!shouldFallbackToChatCompletions(error)) {
throw error;
}
}
const chatPayload = {
model: config.model,
temperature: prompt.temperature ?? config.temperature ?? 0.2,
max_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
messages: [
...(String(prompt.systemPrompt ?? "").trim().length > 0
? [
{
role: "system",
content: String(prompt.systemPrompt ?? "").trim()
}
]
: []),
...(String(prompt.developerPrompt ?? "").trim().length > 0
? [
{
role: "developer",
content: String(prompt.developerPrompt ?? "").trim()
}
]
: []),
{
role: "user",
content: String(prompt.userMessage ?? "")
}
]
};
const raw = await this.postChatCompletions(config, chatPayload);
return {
raw,
outputText: extractOutputTextFromChatCompletions(raw),
usage: extractUsage(raw)
};
}
async listModels(config) {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];

View File

@ -19,6 +19,18 @@ function toNumberFlag(value: string | undefined, defaultValue: number): number {
return Number.isFinite(parsed) ? parsed : defaultValue;
}
function toStringListFlag(value: string | undefined, defaultValue: string[]): string[] {
const source = String(value ?? "").trim();
if (!source) {
return [...defaultValue];
}
const tokens = source
.split(/[,\s;]+/g)
.map((item) => item.trim())
.filter((item) => item.length > 0);
return tokens.length > 0 ? Array.from(new Set(tokens)) : [...defaultValue];
}
export const PORT = Number(process.env.PORT ?? 8787);
export const TIMEZONE = process.env.TZ_FALLBACK ?? "Europe/Moscow";
export const DEFAULT_OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
@ -103,6 +115,10 @@ export const FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1,
true
);
export const FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(
process.env.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1,
true
);
export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(
/\/+$/,
""
@ -110,6 +126,8 @@ export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "
export const ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
export const VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]);
export const VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]);
export const DATA_DIR = process.env.DATA_DIR ?? path.resolve(MODULE_ROOT, "data");
export const TRACES_DIR = path.resolve(DATA_DIR, "traces");

View File

@ -5,9 +5,12 @@ const ACCOUNT_PATTERN = /(?:сч[её]т|счет|account)[^0-9]{0,12}(\d{2}(?:[
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-_:#]*?(\d{1,3})/iu;
const COUNTERPARTY_PATTERN =
/(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
const CONTRACT_PATTERN = /(?:по\s+договору|договор(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const CONTRACT_PATTERN =
/(?:по\s+(?:договору|контракту)|(?:договор|контракт)(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
const DATE_DMY_MONTH_NAME_PATTERN =
/(?:^|[\s,.;:!?()\-])(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2}|\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
const PERIOD_RANGE_PATTERN_1 = /(?:from|с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:to|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
const PERIOD_RANGE_PATTERN_2 =
/(?:between|за\s+период\s+с)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})\s+(?:and|по)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})/i;
@ -116,6 +119,45 @@ function extractAsOfDate(text: string): string | undefined {
return toIsoDate(year, month, day) ?? undefined;
}
const dmyByMonthName = text.match(DATE_DMY_MONTH_NAME_PATTERN);
if (dmyByMonthName) {
const day = Number(dmyByMonthName[1]);
const month = resolveMonthByName(String(dmyByMonthName[2] ?? ""));
const yearRaw = Number(dmyByMonthName[3]);
const year = yearRaw < 100 ? 2000 + yearRaw : yearRaw;
if (month) {
return toIsoDate(year, month, day) ?? undefined;
}
}
return undefined;
}
function extractAsOfDateWithCue(text: string): string | undefined {
const source = String(text ?? "");
if (!source) {
return undefined;
}
const numericCue = source.match(
/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,4}[.\/-]\d{1,2}[.\/-]\d{1,4})(?=$|[\s,.;:!?()\-])/iu
);
if (numericCue) {
return parseDateToken(String(numericCue[1] ?? ""));
}
const monthNameCue = source.match(
/(?:^|[\s,.;:!?()\-])(?:на|до|к|по\s+состоянию\s+на|as\s+of|by)\s+(\d{1,2})\s+([a-zа-яё]+)\s+((?:19|20)\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu
);
if (monthNameCue) {
const day = Number(monthNameCue[1]);
const month = resolveMonthByName(String(monthNameCue[2] ?? ""));
const year = Number(monthNameCue[3]);
if (month && Number.isFinite(year) && Number.isFinite(day)) {
return toIsoDate(year, month, day) ?? undefined;
}
}
return undefined;
}
@ -163,6 +205,26 @@ function resolveMonthByName(rawMonthName: string): number | undefined {
return undefined;
}
function deriveQuarterWindowForDate(asOfIso: string): { period_from: string; period_to: string } | null {
const token = String(asOfIso ?? "").trim();
const match = token.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 quarterStartMonth = Math.floor((month - 1) / 3) * 3 + 1;
const quarterEndMonth = quarterStartMonth + 2;
const quarterEndDay = new Date(Date.UTC(year, quarterEndMonth, 0)).getUTCDate();
return {
period_from: `${year}-${String(quarterStartMonth).padStart(2, "0")}-01`,
period_to: `${year}-${String(quarterEndMonth).padStart(2, "0")}-${String(quarterEndDay).padStart(2, "0")}`
};
}
function extractMonthPeriod(text: string): { period_from?: string; period_to?: string } {
const numericMonthYearMatch = text.match(MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN);
if (numericMonthYearMatch) {
@ -321,8 +383,13 @@ function extractYearRangePeriod(text: string): { period_from?: string; period_to
}
function cleanupAnchorValue(value: string): string {
const normalized = String(value ?? "").trim();
if (!normalized) {
const stripOuterQuotes = (text: string): string =>
String(text ?? "")
.replace(/^['"«»`]+|['"«»`]+$/gu, "")
.trim();
let cleaned = stripOuterQuotes(String(value ?? "").trim());
if (!cleaned) {
return "";
}
@ -330,49 +397,47 @@ function cleanupAnchorValue(value: string): string {
// "<anchor> на 2020-07-31", "<anchor> на дату 31.07.2020", "<anchor> as of 2020-07-31".
const asOfTailPattern =
/\s+(?:на\s+(?:дат[ауеы]\s+)?\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|as\s+of\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?)(?:\s+|$)[\s\S]*$/iu;
if (asOfTailPattern.test(normalized)) {
return normalized.replace(asOfTailPattern, "").trim();
}
const asOfTruncatedTailPattern = /\s+на\s+дат[ауеы]\s+\d{1,2}(?:\s+|$)[\s\S]*$/iu;
if (asOfTruncatedTailPattern.test(normalized)) {
return normalized.replace(asOfTruncatedTailPattern, "").trim();
}
const asOfReportDateTailPattern =
/\s+на\s+дат[ауеы]\s+(?:отчетност[ьи]|отч[её]тн(?:ую|ой)?\s+дат[ауеы]|конец(?:\s+период[а-яё]*)?)\s+\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?(?:\s+|$)[\s\S]*$/iu;
const periodEndTailPattern =
/\s+на\s+конец(?:\s+период[а-яё]*)?\s+(?:\d{1,4}[./-]\d{1,2}(?:[./-]\d{1,4})?|\d{4}|[a-zа-яё]+\s+\d{4})(?:\s+|$)[\s\S]*$/iu;
if (periodEndTailPattern.test(normalized)) {
return normalized.replace(periodEndTailPattern, "").trim();
}
// Remove trailing period qualifiers that can be swallowed by broad anchor regexes:
// "<counterparty> с 2020-07-01 по 2020-07-31", "<counterparty> from 2020-07-01 to 2020-07-31"
const periodTailPattern =
/\s+(?:с\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|from\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|between\s+\d{1,4}[./-]\d{1,2}[./-]\d{1,4}|за\s+период)(?:\s+|$)[\s\S]*$/iu;
if (periodTailPattern.test(normalized)) {
return normalized.replace(periodTailPattern, "").trim();
}
const allTimeTailPattern =
/\s+за\s+(?:вс[её]\s+время|весь\s+период|весь\s+срок|всю\s+истори(?:ю|и)|любой\s+период|любой\s+срок)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPattern.test(normalized)) {
return normalized.replace(allTimeTailPattern, "").trim();
}
const allTimeTailPatternEn =
/\s+(?:for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period|for\s+full\s+history|full\s+history)(?:\s+|$)[\s\S]*$/iu;
if (allTimeTailPatternEn.test(normalized)) {
return normalized.replace(allTimeTailPatternEn, "").trim();
for (const tailPattern of [
asOfTailPattern,
asOfTruncatedTailPattern,
asOfReportDateTailPattern,
periodEndTailPattern,
periodTailPattern,
allTimeTailPattern,
allTimeTailPatternEn
]) {
if (tailPattern.test(cleaned)) {
cleaned = stripOuterQuotes(cleaned.replace(tailPattern, "").trim());
}
}
const trailingYearTailPattern =
/\s+(?:year\s+)?(20\d{2})(?:\s*(?:г(?:од|ода)?\.?|year))?(?:\s+|$)[\s\S]*$/iu;
let cleaned = normalized;
if (trailingYearTailPattern.test(normalized)) {
cleaned = normalized.replace(trailingYearTailPattern, "").trim();
if (trailingYearTailPattern.test(cleaned)) {
cleaned = stripOuterQuotes(cleaned.replace(trailingYearTailPattern, "").trim());
}
return cleaned
cleaned = cleaned
.replace(/\s+(?:from|to|between|and)(?:\s+|$)[\s\S]*$/iu, "")
.replace(/\s+(?:с|по|за)(?:\s+|$)[\s\S]*$/iu, "")
.trim();
return stripOuterQuotes(cleaned);
}
function cleanupContractAnchorValue(value: string): string {
@ -442,6 +507,8 @@ function extractLooseByAnchorValue(text: string): string | undefined {
"партнера",
"договору",
"договора",
"контракту",
"контракта",
"счету",
"счёту",
"дате",
@ -489,6 +556,7 @@ function extractLooseByAnchorValue(text: string): string | undefined {
"linked",
"нему",
"ней",
"нее",
"ним",
"этому",
"тому",
@ -552,10 +620,51 @@ function isLikelyCounterpartyToken(rawToken: string): boolean {
"каких",
"какому",
"какую",
"кто",
"что",
"чего",
"где",
"когда",
"почему",
"зачем",
"сколько",
"чьи",
"чья",
"чей",
"чью",
"самый",
"самая",
"самое",
"самые",
"крупный",
"крупная",
"крупное",
"крупные",
"жирный",
"жирная",
"жирное",
"жирные",
"больше",
"меньше",
"платит",
"платят",
"прогноз",
"forecast",
"план",
"плана",
"ндс",
"vat",
"налог",
"оплата",
"оплаты",
"платеж",
"платёж",
"платежа",
"платежи",
"денег",
"деньги",
"объем",
"объём",
"док",
"доки",
"документ",
@ -685,6 +794,14 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
if (tokens.length === 0) {
return true;
}
const questionCue =
/(?:кто|что|какой|какая|какие|какого|сколько|где|когда|почему|зачем|which|who|what|how\s+many)/iu.test(value) ||
/[?]/u.test(String(rawValue ?? ""));
const rankingCue = /(?:больше|меньше|сам(?:ый|ая|ое|ые)|крупн|жирн|максим|миним)/iu.test(value);
const paymentCue = /(?:плат(?:ит|ят|еж|ёж|ежн|ежей|ежа)|денег|деньг|money|payment)/iu.test(value);
if (questionCue && (rankingCue || paymentCue)) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0;
}
@ -723,7 +840,9 @@ function isLowQualityContractAnchorValue(rawValue: string): boolean {
"период",
"периоду",
"договор",
"договору"
"договору",
"контракт",
"контракту"
]);
const meaningfulTokens = tokens.filter((token) => !lowQualityTokens.has(token));
return meaningfulTokens.length === 0;
@ -941,7 +1060,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value";
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast";
const filters: AddressFilterSet = {
sort: "period_desc"
};
@ -949,6 +1069,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
filters.limit = 20;
}
const warnings: string[] = [];
const explicitAsOfDate = extractAsOfDate(text);
const explicitAsOfDateWithCue = extractAsOfDateWithCue(text);
const accountMatch = text.match(ACCOUNT_PATTERN);
if (accountMatch) {
@ -1071,12 +1193,27 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
}
}
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
if (quarterWindow) {
filters.period_from = quarterWindow.period_from;
warnings.push("period_from_derived_from_quarter_for_vat_forecast");
filters.period_to = vatAsOfDate;
warnings.push("period_to_derived_from_as_of_date_for_vat_forecast");
if (filters.period_from && filters.period_to && filters.period_from > filters.period_to) {
filters.period_from = quarterWindow.period_from;
warnings.push("period_from_adjusted_for_vat_as_of_window");
}
}
}
if (isManagementProfileIntent && !filters.period_to && !filters.as_of_date) {
filters.period_to = new Date().toISOString().slice(0, 10);
warnings.push("period_to_defaulted_today_for_management_profile");
}
const explicitAsOfDate = extractAsOfDate(text);
if (usesAsOfPrimaryWindow(intent) && explicitAsOfDate) {
filters.as_of_date = explicitAsOfDate;
const periodWasDerivedHeuristically =

View File

@ -57,7 +57,8 @@ const OPEN_CONTRACTS_HINTS = [
"незакрыт",
"не закрыт",
"открыт",
"договор"
"договор",
"контракт"
];
const OPEN_ITEMS_HINTS = [
@ -130,7 +131,10 @@ const DOCUMENTS_BY_CONTRACT_HINTS = [
"доки по договору",
"док по договору",
"документы договор",
"договор"
"договор",
"документы по контракту",
"доки по контракту",
"контракт"
];
const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank operations by contract",
@ -140,7 +144,10 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
"bank ops by contract",
"банковские операции по договору",
"платежи по договору",
"выписка по договору"
"выписка по договору",
"банковские операции по контракту",
"платежи по контракту",
"выписка по контракту"
];
const BANK_OPERATION_CORE_HINTS = [
@ -337,14 +344,22 @@ const CONTRACT_USAGE_AND_VALUE_HINTS = [
"договоры по обороту",
"договоры по сумме оборота",
"топ договоров по обороту",
"контракты по обороту",
"контракты по сумме оборота",
"топ контрактов по обороту",
"договоры с минимальным бюджетом",
"договоры с самым маленьким бюджетом",
"контракты с минимальным бюджетом",
"контракты с самым маленьким бюджетом",
"активные договоры по бюджету",
"активные контракты по бюджету",
"контрагенты с несколькими договорами",
"несколько договоров у контрагента",
"мультидоговорные контрагенты",
"какие договоры активны",
"какие контракты активны",
"рабочие договоры",
"рабочие контракты",
"contracts by turnover",
"contracts by budget"
];
@ -355,6 +370,10 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
"список договоров по",
"покажи договоры по",
"выведи договоры по",
"контракты по",
"список контрактов по",
"покажи контракты по",
"выведи контракты по",
"contracts by counterparty",
"list contracts by counterparty",
"show contracts by counterparty"
@ -453,8 +472,25 @@ function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean {
}
function hasCompactAccountCodeToken(text: string): boolean {
// Match compact account tokens like 60.01 / 62, while avoiding date fragments.
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})?(?![\d-])/u.test(text);
// Match compact account tokens while reducing false positives on short-year literals like "22 год".
const source = String(text ?? "");
if (!source) {
return false;
}
// Safe compact form: 60.01 / 62.1
if (/(?<![\d-])\d{2}[.,]\d{1,2}(?![\d-])/u.test(source)) {
return true;
}
// Plain two-digit code is accepted only in explicit account context.
if (/(?:сч[её]т|account)\D{0,12}\d{2}(?![\d-])/iu.test(source)) {
return true;
}
if (/(?:^|\s)по\s+\d{2}(?=$|[\s,.;:!?])/iu.test(source)) {
if (!/(?:^|\s)(?:за|в)\s+\d{2}\s*(?:г(?:од|ода)?|year)\b/iu.test(source)) {
return true;
}
}
return false;
}
function hasDocumentsFormingBalanceSignal(text: string): boolean {
@ -517,6 +553,18 @@ function hasAccountBalanceSignal(text: string): boolean {
return hasAccountLexeme && hasAsOfStyleDate && hasFollowupBalanceVerb;
}
function hasForecastTaxSignal(text: string): boolean {
const hasForecastLexeme =
/(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text);
const hasVatPayableEstimatePattern =
/(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test(
text
);
return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern);
}
function hasPeriodCoverageProfileSignal(text: string): boolean {
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
return true;
@ -670,21 +718,21 @@ function hasContractUsageOverviewSignal(text: string): boolean {
return true;
}
if (
/(?:сколько\s+(?:всего\s+)?договор(?:ов|а)?(?:\s+заведен[оы])?|договорн(?:ая|ой)\s+баз[аы]).*(?:сколько|used|использ)/iu.test(
/(?:сколько\s+(?:всего\s+)?(?:договор|контракт)(?:ов|а)?(?:\s+заведен[оы])?|(?:договорн(?:ая|ой)|контрактн(?:ая|ой))\s+баз[аы]).*(?:сколько|used|использ)/iu.test(
text
)
) {
return true;
}
if (
/(?:сколько\s+из\s+договор(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)
/(?:сколько\s+из\s+(?:договор|контракт)(?:ов|а)?\s+(?:реально\s+)?использ(?:ован[оы]|овал(?:и|ось)?))/iu.test(text)
) {
return true;
}
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|contract)?/iu.test(text)) {
if (/(?:total\s+vs\s+used|used\s+vs\s+total).*(?:договор|контракт|contract)?/iu.test(text)) {
return true;
}
if (/(?:какие\s+договор(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
if (/(?:какие\s+(?:договор|контракт)(?:ы|а)?).*(?:давно\s+не\s+использ|неиспольз|протух|мертв|мёртв|stale|unused)/iu.test(text)) {
return true;
}
return false;
@ -699,14 +747,18 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
}
const hasFuzzyCustomerLexeme = hasFuzzyLexeme(text, ["клиент", "заказчик", "покупател", "customer", "client"]);
const hasFuzzySupplierLexeme = hasFuzzyLexeme(text, ["поставщик", "supplier", "vendor"]);
const hasCounterpartyLexeme = /(?:контрагент(?:ов|а|ы)?|counterpart(?:y|ies)|компан(?:и|ия|ии|ию)|организац(?:и|ия|ии|ию)|partner(?:s)?)/iu.test(
text
);
const hasSpecificCounterpartyAnchor =
hasLooseByAnchorMention(text) ||
hasHeuristicCounterpartyAnchor(text) ||
/(?:по\s+(?:клиент(?:у|а)?|заказчик(?:у|а)?|покупател(?:ю|я)|customer|client)\s+[a-zа-яё0-9])/iu.test(text);
const asksWhoPays = /(?:кто\s+(?:нам\s+)?(?:(?:больше|чаще)\s+)?плат(?:ит|ят)?)/iu.test(text);
const asksCustomerGroup =
/(?:клиент(?:ов|а|ы)?|заказчик(?:ов|а|и)?|покупател(?:ей|я|и)?|customer(?:s)?|client(?:s)?)/iu.test(text) ||
hasFuzzyCustomerLexeme ||
/(?:кто\s+нам\s+(?:больше|чаще)|кто\s+платит)/iu.test(text);
asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksDealBudgetRanking =
@ -714,8 +766,10 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(
text
);
const asksRevenueTotal = /(?:сколько|скока|скок).*(?:денег|выручк|доход|заработ|оборот)/iu.test(text);
const asksOverallTurnover = /(?:общ(?:ий|ие|ая)\s+оборот|общ(?:ая|ий)\s+выручк|total\s+turnover|turnover\s+total)/iu.test(text);
const asksValue =
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal)/iu.test(
/(?:доходн|выручк|приход|поступлен|входящ|зачислен|оплат|плат(?:еж|ёж|ежн|ежей|ежа|ит|ят)|деньг|денег|заработ|оборот|чек|сделк|бюджет|занес|занёс|принес|принёс|revenue|inflow|deal|turnover)/iu.test(
text
);
const asksRankOrTop = /(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн)/iu.test(
@ -731,6 +785,15 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
if (asksCustomerGroup && (asksValue || asksRankOrTop)) {
return true;
}
if (!hasFuzzySupplierLexeme && hasCounterpartyLexeme && asksRankOrTop && (asksValue || asksWhoPays)) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}
if (asksCounterpartySource && asksValue) {
return true;
}
@ -780,13 +843,13 @@ function hasContractUsageAndValueSignal(text: string): boolean {
if (hasAny(text, CONTRACT_USAGE_AND_VALUE_HINTS)) {
return true;
}
if (!/(?:договор(?:ов|а|ы)?|contract(?:s)?)/iu.test(text)) {
if (!/(?:договор(?:ов|а|ы)?|контракт(?:ов|а|ы|у|ом|е)?|contract(?:s)?)/iu.test(text)) {
return false;
}
if (hasContractUsageOverviewSignal(text)) {
return false;
}
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+договор|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+договор|какие\s+договор(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+договор)/iu.test(
const asksStructure = /(?:нескольк(?:ими|их|ие|о)?\s+(?:договор|контракт)|мультидоговор|контрагент(?:ов|ы)?.*нескольк(?:ими|их|ие|о)\s+(?:договор|контракт)|какие\s+(?:договор|контракт)(?:ы|а)?\s+активн|рабоч(?:ие|их)\s+(?:договор|контракт))/iu.test(
text
);
const asksValue =
@ -796,7 +859,7 @@ function hasContractUsageAndValueSignal(text: string): boolean {
}
function hasContractListByCounterpartySignal(text: string): boolean {
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
const hasContractLexeme = /(?:договор(?:а|у|ом|е|ы)?|контракт(?:а|у|ом|е|ы)?|contracts?|contract)/iu.test(text);
if (!hasContractLexeme) {
return false;
}
@ -841,7 +904,7 @@ function hasDocumentsByAccountDrilldownSignal(text: string): boolean {
}
function hasOpenContractsListSignal(text: string): boolean {
const hasContractLexeme = text.includes("договор") || text.includes("contract") || text.includes("dogovor");
const hasContractLexeme = text.includes("договор") || text.includes("контракт") || text.includes("contract") || text.includes("dogovor");
const hasOpenLexeme = /(?:незакрыт|не\s+закрыт|открыт|open|unclosed)/iu.test(text);
if (!hasContractLexeme || !hasOpenLexeme) {
return false;
@ -1186,6 +1249,14 @@ function hasAccountNumberAnchor(text: string): boolean {
export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
const text = String(userMessage ?? "").trim().toLowerCase();
if (hasForecastTaxSignal(text)) {
return {
intent: "vat_payable_forecast",
confidence: "high",
reasons: ["forecast_tax_signal_detected"]
};
}
if (hasAny(text, RECEIVABLES_STRONG)) {
return {
intent: "list_receivables_counterparties",
@ -1228,7 +1299,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (
hasAny(text, OPEN_ITEMS_HINTS) &&
(text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))
(text.includes("контраг") || text.includes("договор") || text.includes("контракт") || text.includes("counterparty") || text.includes("contract"))
) {
return {
intent: "open_items_by_counterparty_or_contract",
@ -1398,7 +1469,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("контракт") || text.includes("contract"))) {
return {
intent: "list_open_contracts",
confidence: "medium",

View File

@ -20,6 +20,10 @@ const ADDRESS_ACTION_TOKENS = [
"вывед",
"кто",
"кому",
"какой",
"какая",
"какое",
"какую",
"какие",
"каких",
"что по",
@ -67,6 +71,7 @@ const ADDRESS_ENTITY_TOKENS = [
"клиент",
"покупател",
"партнер",
"контракт",
"банк",
"выписк",
"операц",
@ -236,7 +241,17 @@ function hasLooseByAnchorMention(text: string): boolean {
"активности",
"пассивности",
"наименее",
"минимум"
"минимум",
"запрос",
"запросу",
"запроса",
"запросом",
"запросе",
"вопрос",
"вопросу",
"вопроса",
"вопросом",
"вопросе"
]);
return !stopWords.has(token);
}
@ -319,7 +334,17 @@ function hasLikelyCounterpartyToken(text: string): boolean {
"пассивный",
"наименее",
"минимум",
"реже"
"реже",
"запрос",
"запросу",
"запроса",
"запросом",
"запросе",
"вопрос",
"вопросу",
"вопроса",
"вопросом",
"вопросе"
]);
const tokens = String(text ?? "")
.split(/[^a-zа-яё0-9._-]+/iu)
@ -386,11 +411,11 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
};
}
if ((hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
if (hasAccountCode && !hasDeepReasoning) {
return {
mode: "address_query",
confidence: "medium",
reasons: ["address_entity_detected"]
reasons: ["account_code_detected"]
};
}

View File

@ -1037,6 +1037,11 @@ export class AddressQueryService {
return null;
}
const { mode, shape, intent, filters, baseReasons } = decompose;
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined
});
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const recipeSelection = selectAddressRecipe(intent.intent, filters.extracted_filters);
@ -1273,7 +1278,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
const factual = composeFactualReply(intent.intent, recoveredRows, { userMessage });
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
const recoveryReason =
recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
@ -1392,7 +1397,11 @@ export class AddressQueryService {
rowsAnchorMatched: expandedRowsByAnchor.length,
rowsMatched: expandedFilteredRows.length
});
const expandedFactual = composeFactualReply(intent.intent, expandedFilteredRows, { userMessage });
const expandedFactual = composeFactualReply(
intent.intent,
expandedFilteredRows,
composeOptionsFromFilters(expandedLimitFilters)
);
const expandedPrefix = `Период сохранен. Глубина live-выборки автоматически расширена до ${expandedPlan.limit} строк.`;
const expandedLimitations = [...filters.warnings, "query_limit_auto_expanded_for_anchor_recovery"];
const expandedReasons = [...baseReasons, "query_limit_auto_expanded_for_anchor_recovery"];
@ -1501,7 +1510,11 @@ export class AddressQueryService {
});
const observedWindow = deriveObservedPeriodWindow(broadenedFilteredRows);
const broadenedPrefix = composeAutoBroadenedPeriodPrefix(filters.extracted_filters, observedWindow);
const broadenedFactual = composeFactualReply(intent.intent, broadenedFilteredRows, { userMessage });
const broadenedFactual = composeFactualReply(
intent.intent,
broadenedFilteredRows,
composeOptionsFromFilters(autoBroadenedFilters)
);
const broadenedLimitations = [...filters.warnings, "period_window_auto_broadened_to_available_data"];
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
@ -1616,14 +1629,21 @@ export class AddressQueryService {
rowsAnchorMatched: historicalRowsByAnchor.length,
rowsMatched: historicalFilteredRows.length
});
const historicalFactual = composeFactualReply(intent.intent, historicalFilteredRows, { userMessage });
const historicalPrefix =
"В последних доступных записях якорь не подтвердился; показаны найденные строки по историческому окну.";
const historicalFactual = composeFactualReply(
intent.intent,
historicalFilteredRows,
composeOptionsFromFilters(historicalFilters)
);
const historicalPrefix = "Найдены данные в историческом срезе базы по вашему запросу.";
const historicalSuggestion =
intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно показать платежи и договоры по этому контрагенту."
: "";
const historicalLimitations = [...filters.warnings, "historical_window_sort_recovery_applied"];
const historicalReasons = [...baseReasons, "historical_window_sort_recovery_applied"];
return {
handled: true,
reply_text: `${historicalPrefix}\n${historicalFactual.text}`,
reply_text: `${historicalPrefix}\n${historicalFactual.text}${historicalSuggestion}`,
reply_type: inferReplyType(historicalFactual.responseType),
response_type: historicalFactual.responseType,
debug: {
@ -1681,14 +1701,21 @@ export class AddressQueryService {
) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = composeFactualReply(intent.intent, documentBankFallbackRows, { userMessage });
const fallbackFactual = composeFactualReply(
intent.intent,
documentBankFallbackRows,
composeOptionsFromFilters(filters.extracted_filters)
);
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion =
intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
: "";
const fallbackLimitations = [...filters.warnings, "anchor_not_matched_fallback_rows"];
const fallbackReasons = [...baseReasons, "anchor_not_matched_fallback_rows"];
return {
handled: true,
reply_text:
"Точный якорь не подтвердился в текущем окне live-данных; показаны ближайшие доступные документы/операции по выбранному типу.\n" +
fallbackFactual.text,
reply_text: `${fallbackPrefix}\n${fallbackFactual.text}${fallbackSuggestion}`,
reply_type: inferReplyType(fallbackFactual.responseType),
response_type: fallbackFactual.responseType,
debug: {
@ -1751,11 +1778,24 @@ export class AddressQueryService {
Array.isArray(filters.warnings) &&
(filters.warnings.includes("counterparty_from_followup_context") ||
filters.warnings.includes("contract_from_followup_context"));
const anchorMismatchByCounterparty =
isAnchorMismatch && String(matchFailureReason ?? "").includes("counterparty_anchor_not_matched");
const anchorMismatchByContract = isAnchorMismatch && String(matchFailureReason ?? "").includes("contract_anchor_not_matched");
const isLowQualityPartyAnchor =
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") &&
isLikelyLowQualityPartyAnchor(anchor.anchor_value_raw);
const anchorMismatchCategory: AddressLimitedReasonCategory =
isFollowupAnchorCarryover || !isLowQualityPartyAnchor ? "empty_match" : "missing_anchor";
const requestedPeriodFrom =
typeof filters.extracted_filters.period_from === "string" ? filters.extracted_filters.period_from : null;
const requestedPeriodTo = typeof filters.extracted_filters.period_to === "string" ? filters.extracted_filters.period_to : null;
const requestedPeriodHint =
requestedPeriodFrom && requestedPeriodTo ? ` (период ${requestedPeriodFrom}..${requestedPeriodTo} сохранен)` : "";
const anchorMismatchCategory: AddressLimitedReasonCategory = isFollowupAnchorCarryover
? "empty_match"
: anchorMismatchByCounterparty || anchorMismatchByContract
? "missing_anchor"
: !isLowQualityPartyAnchor
? "empty_match"
: "missing_anchor";
const category: AddressLimitedReasonCategory = isAnchorMismatch
? anchorMismatchCategory
: isRecipeFilteredOut
@ -1764,18 +1804,26 @@ export class AddressQueryService {
? "recipe_visibility_gap"
: "empty_match";
const reasonText = isAnchorMismatch
? anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в материализованных live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
? anchorMismatchByCounterparty
? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
: anchorMismatchByContract
? "договор по указанному номеру/названию не найден в materialized live-строках"
: anchorMismatchCategory === "missing_anchor"
? "якорь контрагента/договора не найден в materialized live-строках"
: "по указанному якорю и фильтрам в live-выборке нет строк"
: isRecipeFilteredOut
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
: isVisibilityGapCandidate
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
: "по выбранным фильтрам в live-выборке нет строк";
const nextStep = isAnchorMismatch
? anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
? anchorMismatchByCounterparty
? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
: anchorMismatchByContract
? `уточните номер/наименование договора${requestedPeriodHint}`
: anchorMismatchCategory === "missing_anchor"
? "уточните контрагента точным именем или добавьте ИНН/договор"
: "уточните период или снимите часть фильтров"
: isRecipeFilteredOut
? "сузьте период, уточните контрагента или документный тип"
: isVisibilityGapCandidate
@ -1783,9 +1831,13 @@ export class AddressQueryService {
: "уточните период, контрагента, договор или снимите часть фильтров";
const limitations = isAnchorMismatch
? [
anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
anchorMismatchByCounterparty
? "counterparty_anchor_not_matched_after_materialization"
: anchorMismatchByContract
? "contract_anchor_not_matched_after_materialization"
: anchorMismatchCategory === "missing_anchor"
? "anchor_not_matched_after_materialization"
: "no_rows_for_anchor_after_materialization"
]
: isRecipeFilteredOut
? ["rows_filtered_out_by_recipe_after_anchor_match"]
@ -1824,7 +1876,7 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(intent.intent, filteredRows, { userMessage });
const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
return {
handled: true,
reply_text: factual.text,

View File

@ -4,6 +4,7 @@
AddressRecipeDefinition,
AddressRecipeSelection
} from "../types/addressQuery";
import { VAT_PAYABLE_19_PREFIXES, VAT_PAYABLE_68_PREFIXES } from "../config";
const MOVEMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
@ -347,6 +348,66 @@ const CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE = `
Справочник.ДоговорыКонтрагентов КАК Договоры
`;
const VAT_PAYABLE_FORECAST_QUERY_TEMPLATE = `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_68_CREDIT" КАК Регистратор,
"68" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT68_KT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_68_DEBIT" КАК Регистратор,
"68" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT68_DT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_19_DEBIT" КАК Регистратор,
"19" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT19_DT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"VAT_19_CREDIT" КАК Регистратор,
"19" КАК СчетДт,
"" КАК СчетКт,
СУММА(ВЫБОР
КОГДА __VAT19_KT_MATCH__
ТОГДА Движения.Сумма
ИНАЧЕ 0
КОНЕЦ) КАК Сумма
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Регистратор
`;
const BASE_RECIPES: AddressRecipeDefinition[] = [
{
recipe_id: "address_period_coverage_profile_v1",
@ -428,6 +489,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
query_template: "contract_value_profile"
},
{
recipe_id: "address_vat_payable_forecast_v1",
intent: "vat_payable_forecast",
purpose: "Estimate VAT payable from factual turnovers on accounts 68 and 19 for selected period",
required_filters: [],
optional_filters: ["period_from", "period_to", "as_of_date", "organization"],
default_limit: 32,
account_scope_mode: "preferred",
query_template: "vat_payable_forecast_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@ -669,6 +740,64 @@ function buildMovementAccountCondition(filters: AddressFilterSet): string | null
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function normalizeAccountPrefixForQuery(value: string): string | null {
const normalized = String(value ?? "")
.trim()
.replace(",", ".")
.replace(/[^0-9.]+/g, "");
if (!normalized) {
return null;
}
if (!/^\d{2}(?:\.\d{1,3})*$/.test(normalized)) {
return null;
}
return normalized;
}
function accountPrefixVariants(prefix: string): string[] {
const value = normalizeAccountPrefixForQuery(prefix);
if (!value) {
return [];
}
const variants = new Set<string>([value]);
const segments = value.split(".");
if (segments.length <= 1) {
return Array.from(variants);
}
const base = segments[0];
const normalizedTail = segments.slice(1).map((segment) => {
const trimmed = segment.replace(/^0+(?=\d)/, "");
return trimmed.length > 0 ? trimmed : "0";
});
const compact = [base, ...normalizedTail].join(".");
if (compact !== value) {
variants.add(compact);
}
return Array.from(variants);
}
function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): string {
const normalizedPrefixes = Array.from(
new Set(
(prefixes ?? [])
.flatMap((item) => accountPrefixVariants(item))
.filter((item): item is string => Boolean(item))
)
);
if (normalizedPrefixes.length === 0) {
return "ЛОЖЬ";
}
const clauses = normalizedPrefixes.map(
(prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`
);
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function shouldBoostLimitForAllTimeCounterparty(filters: AddressFilterSet): boolean {
const hasAnchor =
(typeof filters.counterparty === "string" && filters.counterparty.trim().length > 0) ||
@ -694,6 +823,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" ||
intent === "list_contracts_by_counterparty" ||
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@ -742,7 +872,8 @@ export function buildAddressRecipePlan(
recipe.query_template === "period_profile" ||
recipe.query_template === "document_section_profile" ||
recipe.query_template === "counterparty_roles_profile" ||
recipe.query_template === "contract_usage_profile";
recipe.query_template === "contract_usage_profile" ||
recipe.query_template === "vat_payable_forecast_profile";
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
@ -830,6 +961,13 @@ export function buildAddressRecipePlan(
buildContractValueWhereClause(filters, "БанкСписание.Дата", "БанкСписание.ДоговорКонтрагента")
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort))
: recipe.query_template === "vat_payable_forecast_profile"
? VAT_PAYABLE_FORECAST_QUERY_TEMPLATE
.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
.replaceAll("__VAT68_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES))
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: MOVEMENTS_QUERY_TEMPLATE

View File

@ -11,6 +11,8 @@ export interface ComposeStageRow {
interface ComposeFactualReplyOptions {
userMessage?: string;
periodFrom?: string;
periodTo?: string;
}
type PeriodProfileFocus =
@ -130,6 +132,93 @@ function formatPercent(value: number, total: number): string | null {
return `${((value / total) * 100).toFixed(1)}%`;
}
function formatMoney(value: number): string {
if (!Number.isFinite(value)) {
return "0.00";
}
return value.toFixed(2);
}
function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null {
const source = String(value ?? "").trim();
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
return { year, month, day };
}
function toIsoDate(year: number, month: number, day: number): string {
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function formatDateRu(isoDate: string): string {
const parsed = parseIsoDateToken(isoDate);
if (!parsed) {
return isoDate;
}
return `${String(parsed.day).padStart(2, "0")}.${String(parsed.month).padStart(2, "0")}.${String(parsed.year).padStart(4, "0")}`;
}
function buildIsoDateWithMonthShift(
year: number,
monthOneBased: number,
day: number,
monthShift = 0
): string {
const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
return date.toISOString().slice(0, 10);
}
function deriveVatDeadlineCalendar(
periodFrom: string | null | undefined,
periodTo: string | null | undefined
): {
periodLabel: string;
quarterStart: string;
quarterEnd: string;
declarationDueDate: string;
paymentDueDates: [string, string, string];
windowFrom: string | null;
windowTo: string | null;
} | null {
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
if (!reference) {
return null;
}
const quarterIndex = Math.floor((reference.month - 1) / 3);
const quarterNumber = quarterIndex + 1;
const quarterStartMonth = quarterIndex * 3 + 1;
const quarterEndMonth = quarterStartMonth + 2;
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
return {
periodLabel: `${quarterNumber} кв. ${reference.year}`,
quarterStart,
quarterEnd,
declarationDueDate,
paymentDueDates: [payment1, payment2, payment3],
windowFrom: periodFrom ?? null,
windowTo: periodTo ?? null
};
}
function extractAccountSectionCode(value: string | null): string | null {
const source = String(value ?? "").trim();
if (!source) {
@ -150,6 +239,18 @@ function normalizeQuestionText(value: string | null | undefined): string {
.trim();
}
function needsVatWhyExplanation(userMessage: string | null | undefined): boolean {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
const asksReason = /(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text);
if (!asksReason) {
return false;
}
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
}
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
const text = normalizeQuestionText(userMessage);
if (!text) {
@ -249,13 +350,13 @@ function detectCounterpartyProfileFocus(userMessage: string | null | undefined):
text
);
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken && !asksTotal) {
if (hasSupplierToken && !hasCustomerToken && !hasMixedToken) {
return "suppliers_only";
}
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken && !asksTotal) {
if (hasCustomerToken && !hasSupplierToken && !hasMixedToken) {
return "customers_only";
}
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken && !asksTotal) {
if (hasMixedToken && !hasSupplierToken && !hasCustomerToken) {
return "mixed_only";
}
@ -781,8 +882,19 @@ export function composeFactualReply(
const focus = detectCounterpartyProfileFocus(options.userMessage);
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
const directLead =
focus === "suppliers_only"
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
: focus === "customers_only"
? `Заказчиков (только customer-роль): ${customerOnly}.`
: focus === "mixed_only"
? `Смешанных контрагентов (и customer, и supplier): ${mixedActive}.`
: includeTotal && totalCounterparties > 0
? `Всего уникальных контрагентов в базе: ${totalCounterparties}.`
: `Активных контрагентов по операциям: ${activeCounterparties}.`;
const lines: string[] = [
directLead,
"Профиль контрагентов собран (catalog + bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
@ -871,19 +983,19 @@ export function composeFactualReply(
: "в выбранном периоде";
const lines: string[] = [
`Активные заказчики ${scopeLabel}: ${counterparties.length}.`,
"Собран профиль активности заказчиков (bank-doc activity aggregate).",
`Строк агрегата: ${rows.length}.`
];
if (counterparties.length === 0) {
lines.push("Активных заказчиков по выбранному окну не найдено.");
lines.push("По выбранному окну активности заказчики не найдены.");
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
lines.push(`Активные заказчики ${scopeLabel}: ${counterparties.length}.`);
const visible = counterparties.slice(0, 120);
lines.push(
...visible.map((item, index) => {
@ -923,7 +1035,13 @@ export function composeFactualReply(
totalContracts > 0 ? Math.max(0, totalContracts - Math.min(usedContracts, totalContracts)) : null;
const usedShare = totalContracts > 0 ? formatPercent(Math.min(usedContracts, totalContracts), totalContracts) : null;
const usageLead =
totalContracts > 0
? `Использованных договоров: ${usedContracts} из ${totalContracts}${usedShare ? ` (${usedShare})` : ""}.`
: `Использованных договоров (есть factual связь с операциями): ${usedContracts}.`;
const lines: string[] = [
usageLead,
"Профиль договорной базы собран (catalog + usage aggregate).",
`Строк агрегата: ${rows.length}.`
];
@ -1046,11 +1164,10 @@ export function composeFactualReply(
if (focus === "top_by_ops") {
const visible = rankedByOps.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`
);
const heading = isSupplier
? `Топ-${visible.length} поставщиков по количеству исходящих платежных операций:`
: `Топ-${visible.length} заказчиков по количеству входящих платежных операций:`;
lines.unshift(heading);
lines.push(
...visible.map(
(item, index) => `${index + 1}. ${item.name} | операций: ${item.ops} | сумма: ${item.total} | макс: ${item.maxSingle}`
@ -1064,11 +1181,10 @@ export function composeFactualReply(
if (focus === "top_by_max_single") {
const visible = rankedByMaxSingle.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`
);
const heading = isSupplier
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
lines.unshift(heading);
lines.push(
...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | сумма: ${item.total} | операций: ${item.ops}`)
);
@ -1080,11 +1196,10 @@ export function composeFactualReply(
if (focus === "top_by_avg_check_min_ops") {
const visible = rankedByAvgCheck.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`
);
const heading = isSupplier
? `Топ-${visible.length} поставщиков по среднему чеку (минимум ${minOpsForAvgCheck} операций):`
: `Топ-${visible.length} заказчиков по среднему чеку (минимум ${minOpsForAvgCheck} входящих операций):`;
lines.unshift(heading);
if (visible.length === 0) {
lines.push(`Контрагентов с минимум ${minOpsForAvgCheck} операций не найдено.`);
} else {
@ -1103,11 +1218,10 @@ export function composeFactualReply(
if (focus === "top_deals") {
const visible = rankedDealsTop.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`
);
const heading = isSupplier
? `Топ-${visible.length} самых крупных разовых выплат поставщикам:`
: `Топ-${visible.length} самых крупных разовых сделок по поступлениям:`;
lines.unshift(heading);
lines.push(
...visible.map(
(item, index) => `${index + 1}. ${item.period ?? "n/a"} | ${item.counterparty} | ${item.registrator} | ${item.amount}`
@ -1121,11 +1235,10 @@ export function composeFactualReply(
if (focus === "bottom_deals") {
const visible = rankedDealsBottom.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`
);
const heading = isSupplier
? `Топ-${visible.length} самых маленьких разовых выплат:`
: `Топ-${visible.length} самых маленьких разовых сделок по поступлениям:`;
lines.unshift(heading);
if (activeOnlyForBottomDeals) {
lines.push("Фильтр: только активные контрагенты (минимум 3 операции).");
}
@ -1141,11 +1254,10 @@ export function composeFactualReply(
}
const visible = rankedByTotal.slice(0, limit);
lines.push(
isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`
);
const heading = isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
lines.unshift(heading);
lines.push(
...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? (item.total / item.ops).toFixed(2) : "0";
@ -1212,9 +1324,10 @@ export function composeFactualReply(
.sort((a, b) => a.turnover - b.turnover || b.docs - a.docs || a.contract.localeCompare(b.contract));
const lines: string[] = [
`Активных договоров: ${contractRows.length}.`,
"Собран профиль договоров по обороту/бюджету (bank-doc contract aggregate).",
`Строк источника: ${rows.length}.`,
`Активных договоров: ${contractRows.length}.`
`Договорных агрегатов: ${contractRows.length}.`
];
if (contractRows.length === 0) {
@ -1227,7 +1340,8 @@ export function composeFactualReply(
if (focus === "top_by_docs") {
const visible = rankedByDocs.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по количеству операций:`);
const heading = `Топ-${visible.length} договоров по количеству операций:`;
lines.unshift(heading);
lines.push(
...visible.map(
(item, index) =>
@ -1242,7 +1356,8 @@ export function composeFactualReply(
if (focus === "bottom_by_turnover_active") {
const visible = rankedBottomActive.slice(0, limit);
lines.push(`Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`);
const heading = `Топ-${visible.length} активных договоров с минимальным бюджетом (оборотом):`;
lines.unshift(heading);
lines.push(
...visible.map(
(item, index) =>
@ -1256,7 +1371,8 @@ export function composeFactualReply(
}
const visible = rankedByTurnover.slice(0, limit);
lines.push(`Топ-${visible.length} договоров по сумме оборота:`);
const heading = `Топ-${visible.length} договоров по сумме оборота:`;
lines.unshift(heading);
lines.push(
...visible.map(
(item, index) =>
@ -1269,6 +1385,98 @@ export function composeFactualReply(
};
}
if (intent === "vat_payable_forecast") {
const rowsByMarker = new Map<string, number>();
for (const row of rows) {
const marker = String(row.registrator ?? "").trim().toUpperCase();
if (!marker) {
continue;
}
const nextValue = (rowsByMarker.get(marker) ?? 0) + (row.amount ?? 0);
rowsByMarker.set(marker, nextValue);
}
const turnover68Credit = rowsByMarker.get("VAT_68_CREDIT") ?? 0;
const turnover68Debit = rowsByMarker.get("VAT_68_DEBIT") ?? 0;
const turnover19Debit = rowsByMarker.get("VAT_19_DEBIT") ?? 0;
const turnover19Credit = rowsByMarker.get("VAT_19_CREDIT") ?? 0;
const netVat = turnover68Credit - turnover68Debit;
const vatToPay = Math.max(0, netVat);
const carryoverOrOverpayment = Math.max(0, -netVat);
const totalVatTurnoverAbs =
Math.abs(turnover68Credit) + Math.abs(turnover68Debit) + Math.abs(turnover19Debit) + Math.abs(turnover19Credit);
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const lines = [
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
`Строк агрегата: ${rows.length}.`,
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
];
if (!vatActivityDetected) {
lines.push(
"В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00."
);
} else if (vatToPay === 0 && netVatIsEffectivelyZero) {
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
} else if (vatToPay === 0 && netVat < 0) {
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
}
if (vatToPay === 0) {
lines.push(
"Чеклист проверки в 1С (почему к уплате 0):",
`1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`,
"2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).",
"3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
"4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
"5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов."
);
}
if (vatCalendar) {
const periodWindowLabel =
vatCalendar.windowFrom && vatCalendar.windowTo
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
const [payment1, payment2, payment3] = vatCalendar.paymentDueDates;
const installmentRaw = vatToPay / 3;
const installmentRounded = Number(installmentRaw.toFixed(2));
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
lines.push(
`Период расчета (срез обязательств): ${periodWindowLabel}.`,
`Налоговый период: ${vatCalendar.periodLabel}.`,
`Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`,
`Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`,
`Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`,
"Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
);
}
if (explainWhyRequested) {
lines.push(
"Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
`За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`,
netVat <= 0
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
: "Разница положительная, поэтому к уплате берется эта положительная величина.",
"Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация."
);
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
};
}
if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [
@ -1372,8 +1580,7 @@ export function composeFactualReply(
if (intent === "list_documents_by_counterparty") {
const lines = [
"Собран список документов по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
`Найдено документов по контрагенту: ${rows.length}.`,
...formatTopRows(rows, rows.length)
];
return {
@ -1384,6 +1591,7 @@ export function composeFactualReply(
if (intent === "list_documents_by_contract") {
const lines = [
`Найдено документов по договору: ${rows.length}.`,
"Собран список документов по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@ -1396,6 +1604,7 @@ export function composeFactualReply(
if (intent === "bank_operations_by_counterparty") {
const lines = [
`Найдено банковских операций по контрагенту: ${rows.length}.`,
"Собран список банковских операций по контрагенту (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)
@ -1408,6 +1617,7 @@ export function composeFactualReply(
if (intent === "bank_operations_by_contract") {
const lines = [
`Найдено банковских операций по договору: ${rows.length}.`,
"Собран список банковских операций по договору (live address lane).",
`Строк отобрано: ${rows.length}.`,
...formatTopRows(rows, rows.length)

View File

@ -144,6 +144,29 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"что",
"все",
"всё",
"кроме",
"помимо",
"этого",
"этот",
"эта",
"эту",
"этом",
"это",
"эти",
"этих",
"документ",
"документа",
"документы",
"документов",
"договор",
"договора",
"контрагент",
"контрагента",
"еще",
"ещё",
"другие",
"другое",
"остальное",
"год",
"года",
"году",
@ -284,6 +307,13 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
}
const tokenCount = normalized.split(/\s+/).filter(Boolean).length;
if (
tokenCount <= 12 &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(normalized) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)
) {
return true;
}
const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized);
if (tokenCount <= 8 && hasPeriodLiteral) {
return true;
@ -411,9 +441,28 @@ function mergeFollowupFilters(
}
}
const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage);
const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage);
const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous);
if (!currentHasPeriod && previousHasPeriod && hasAddressFollowupContextSignal(userMessage)) {
if (intent === "vat_payable_forecast" && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage) {
const currentPeriodFrom = toNonEmptyString(merged.period_from);
const currentPeriodTo = toNonEmptyString(merged.period_to);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = !currentPeriodFrom && currentPeriodTo === todayIso;
if (!currentPeriodFrom || currentLooksDefaultedToToday) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
if (previousPeriodTo) {
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
}
}
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) {
if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
@ -562,7 +611,11 @@ export function runAddressDecomposeStage(
): AddressDecomposeStageResult | null {
const detectedMode = detectAddressQuestionMode(userMessage);
const shape = classifyAddressQueryShape(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON") {
const allowExplainAsFollowup =
shape.shape === "EXPLAIN_OR_REASON" &&
Boolean(followupContext?.previous_intent) &&
hasAddressFollowupContextSignal(userMessage);
if (shape.shape === "EXPLAIN_OR_REASON" && !allowExplainAsFollowup) {
return null;
}
const detectedIntent = resolveAddressIntent(userMessage);

View File

@ -85,7 +85,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
intent === "contract_usage_overview" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value"
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast"
) {
return "management_profile";
}

File diff suppressed because it is too large Load Diff

View File

@ -203,6 +203,103 @@ function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] {
}
export class OpenAIResponsesClient {
public async chat(
config: OpenAIRequestConfig,
prompt: {
systemPrompt?: string;
developerPrompt?: string;
userMessage: string;
maxOutputTokens?: number;
temperature?: number;
}
): Promise<OpenAIResponseEnvelope> {
const responsesPayload = {
model: config.model,
temperature: prompt.temperature ?? config.temperature ?? 0.2,
max_output_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
input: [
...(String(prompt.systemPrompt ?? "").trim().length > 0
? [
{
role: "system",
content: [{ type: "input_text", text: String(prompt.systemPrompt ?? "").trim() }]
}
]
: []),
...(String(prompt.developerPrompt ?? "").trim().length > 0
? [
{
role: "developer",
content: [{ type: "input_text", text: String(prompt.developerPrompt ?? "").trim() }]
}
]
: []),
{
role: "user",
content: [{ type: "input_text", text: String(prompt.userMessage ?? "") }]
}
]
};
const provider = resolveProvider(config);
if (provider === "openai") {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
}
try {
const raw = await this.postResponses(config, responsesPayload);
return {
raw,
outputText: extractOutputTextFromResponses(raw),
usage: extractUsage(raw)
};
} catch (error) {
if (!shouldFallbackToChatCompletions(error)) {
throw error;
}
}
const chatPayload = {
model: config.model,
temperature: prompt.temperature ?? config.temperature ?? 0.2,
max_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400,
messages: [
...(String(prompt.systemPrompt ?? "").trim().length > 0
? [
{
role: "system",
content: String(prompt.systemPrompt ?? "").trim()
}
]
: []),
...(String(prompt.developerPrompt ?? "").trim().length > 0
? [
{
role: "developer",
content: String(prompt.developerPrompt ?? "").trim()
}
]
: []),
{
role: "user",
content: String(prompt.userMessage ?? "")
}
]
};
const raw = await this.postChatCompletions(config, chatPayload);
return {
raw,
outputText: extractOutputTextFromChatCompletions(raw),
usage: extractUsage(raw)
};
}
public async listModels(config: OpenAIRequestConfig): Promise<string[]> {
const payload = await this.getModels(config);
const data = Array.isArray(payload.data) ? payload.data : [];

View File

@ -9,6 +9,7 @@ export type AddressIntent =
| "customer_revenue_and_payments"
| "supplier_payouts_profile"
| "contract_usage_and_value"
| "vat_payable_forecast"
| "list_contracts_by_counterparty"
| "list_open_contracts"
| "list_payables_counterparties"
@ -119,7 +120,8 @@ export interface AddressRecipeDefinition {
| "customer_revenue_profile"
| "supplier_payout_profile"
| "contract_value_profile"
| "contracts_by_counterparty_profile";
| "contracts_by_counterparty_profile"
| "vat_payable_forecast_profile";
required_filters: Array<keyof AddressFilterSet>;
optional_filters: Array<keyof AddressFilterSet>;
default_limit: number;

View File

@ -125,6 +125,11 @@ describe("address query shape classifier", () => {
expect(result.mode).toBe("address_query");
});
it("keeps top contract wording with 'контракт' in address lane", () => {
const result = detectAddressQuestionMode("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
expect(result.mode).toBe("address_query");
});
});
describe("address compose stage utf8 headers", () => {
@ -1123,6 +1128,202 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("активных договоров с минимальным бюджетом");
expect(reply.text).toContain("1. Договор 02/20 | оборот: 100");
});
it("adds deterministic why-zero explanation for VAT forecast follow-up wording", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_68_CREDIT",
account_dt: "68",
account_kt: "",
amount: 9126,
analytics: []
},
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_68_DEBIT",
account_dt: "68",
account_kt: "",
amount: 115342,
analytics: []
},
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_19_DEBIT",
account_dt: "19",
account_kt: "",
amount: 1602384,
analytics: []
},
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_19_CREDIT",
account_dt: "19",
account_kt: "",
amount: 0,
analytics: []
}
],
{ userMessage: "почему прогноз к уплате 0?" }
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Почему прогноз к уплате 0");
expect(reply.text).toContain("max(0, 68 Кт - 68 Дт)");
expect(reply.text).toContain("За период 68 Кт = 9126.00, 68 Дт = 115342.00, разница = -106216.00.");
expect(reply.text).toContain("Разница неположительная");
expect(reply.text).toContain("оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*");
});
it("adds VAT declaration and payment deadlines for as-of-date forecast window", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_68_CREDIT",
account_dt: "68",
account_kt: "",
amount: 300,
analytics: []
},
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_68_DEBIT",
account_dt: "68",
account_kt: "",
amount: 0,
analytics: []
}
],
{
userMessage: "сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
periodFrom: "2020-01-01",
periodTo: "2020-03-15"
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Период расчета (срез обязательств): 01.01.2020..15.03.2020.");
expect(reply.text).toContain("Налоговый период: 1 кв. 2020.");
expect(reply.text).toContain("Срок сдачи декларации: до 25.04.2020.");
expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 28.06.2020.");
expect(reply.text).toContain("Ориентир по долям к уплате: 100.00 / 100.00 / 100.00.");
});
it("builds VAT deadlines correctly for Q4 with next-year rollover", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
{
period: "2020-12-31T00:00:00Z",
registrator: "VAT_68_CREDIT",
account_dt: "68",
account_kt: "",
amount: 90,
analytics: []
}
],
{
userMessage: "прогноз НДС на 31 декабря 2020",
periodFrom: "2020-10-01",
periodTo: "2020-12-31"
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Налоговый период: 4 кв. 2020.");
expect(reply.text).toContain("Срок сдачи декларации: до 25.01.2021.");
expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 28.02.2021, 28.03.2021.");
expect(reply.text).toContain("Ориентир по долям к уплате: 30.00 / 30.00 / 30.00.");
});
it("explains zero VAT as no-movements case when VAT turnovers are absent in window", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
{
period: "2019-04-01T00:00:00Z",
registrator: "VAT_68_CREDIT",
account_dt: "68",
account_kt: "",
amount: 0,
analytics: []
},
{
period: "2019-04-01T00:00:00Z",
registrator: "VAT_68_DEBIT",
account_dt: "68",
account_kt: "",
amount: 0,
analytics: []
},
{
period: "2019-04-01T00:00:00Z",
registrator: "VAT_19_DEBIT",
account_dt: "19",
account_kt: "",
amount: 0,
analytics: []
},
{
period: "2019-04-01T00:00:00Z",
registrator: "VAT_19_CREDIT",
account_dt: "19",
account_kt: "",
amount: 0,
analytics: []
}
],
{
userMessage: "какой прогноз оплаты ндс на 12-05-2019",
periodFrom: "2019-04-01",
periodTo: "2019-05-12"
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
expect(reply.text).toContain("не найдено движений по НДС-субсчетам 68.02*/19*");
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
expect(reply.text).toContain("Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный");
});
it("explains zero VAT as offset case when VAT turnovers exist but net is near zero", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_68_CREDIT",
account_dt: "68",
account_kt: "",
amount: 1000,
analytics: []
},
{
period: "2020-03-01T00:00:00Z",
registrator: "VAT_68_DEBIT",
account_dt: "68",
account_kt: "",
amount: 1000,
analytics: []
}
],
{
userMessage: "какой прогноз оплаты ндс на 12-05-2020",
periodFrom: "2020-04-01",
periodTo: "2020-05-12"
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
expect(reply.text).toContain("обороты по 68* взаимно перекрылись");
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
});
});
describe("address intent resolver expansion (M2.3a)", () => {
@ -1468,6 +1669,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves top counterparty slang wording into customer revenue intent", () => {
const result = resolveAddressIntent("какой самый жирный контрагент у нее? кто больше платит денег");
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves supplier payouts profile intent from slang wording", () => {
const result = resolveAddressIntent("кому мы больше всего сгрузили денег, топ-20 поставщиков");
expect(result.intent).toBe("supplier_payouts_profile");
@ -1478,6 +1684,33 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("contract_usage_and_value");
});
it("resolves top contract wording with 'контракт' into contract usage and value intent", () => {
const result = resolveAddressIntent("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
expect(result.intent).toBe("contract_usage_and_value");
});
it("resolves revenue-total slang wording into customer revenue intent", () => {
const result = resolveAddressIntent("скока денег альтернатива заработала за 22 год");
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves overall-turnover wording into customer revenue intent", () => {
const result = resolveAddressIntent("какие общие обороты за все время");
expect(result.intent).toBe("customer_revenue_and_payments");
});
it("resolves VAT payment forecast wording into dedicated VAT forecast intent", () => {
const result = resolveAddressIntent("какой прогноз оплаты ндс за 12 мая 2020");
expect(result.intent).toBe("vat_payable_forecast");
expect(result.reasons).toContain("forecast_tax_signal_detected");
});
it("resolves colloquial VAT payable estimate wording without explicit 'прогноз'", () => {
const result = resolveAddressIntent("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
expect(result.intent).toBe("vat_payable_forecast");
expect(result.reasons).toContain("forecast_tax_signal_detected");
});
it("resolves multi-contract counterparties wording into contract usage and value intent", () => {
const result = resolveAddressIntent("Покажи контрагентов с несколькими договорами и какие из договоров активны.");
expect(result.intent).toBe("contract_usage_and_value");
@ -1525,6 +1758,7 @@ describe("address filter extraction for balance drilldown", () => {
"договоры по обороту ранкни и дай топ-20",
"contract_usage_and_value"
);
const vatForecast = extractAddressFilters("какой прогноз оплаты ндс за 12 мая 2020", "vat_payable_forecast");
expect(periodProfile.extracted_filters.limit).toBeUndefined();
expect(docSectionProfile.extracted_filters.limit).toBeUndefined();
expect(counterpartyProfile.extracted_filters.limit).toBeUndefined();
@ -1533,6 +1767,7 @@ describe("address filter extraction for balance drilldown", () => {
expect(customerValue.extracted_filters.limit).toBe(20);
expect(supplierValue.extracted_filters.limit).toBe(20);
expect(contractValue.extracted_filters.limit).toBe(20);
expect(vatForecast.extracted_filters.limit).toBeUndefined();
expect(periodProfile.extracted_filters.period_to).toBeDefined();
expect(docSectionProfile.extracted_filters.period_to).toBeDefined();
expect(counterpartyProfile.extracted_filters.period_to).toBeDefined();
@ -1541,6 +1776,8 @@ describe("address filter extraction for balance drilldown", () => {
expect(customerValue.extracted_filters.period_to).toBeDefined();
expect(supplierValue.extracted_filters.period_to).toBeDefined();
expect(contractValue.extracted_filters.period_to).toBeDefined();
expect(vatForecast.extracted_filters.period_from).toBe("2020-04-01");
expect(vatForecast.extracted_filters.period_to).toBe("2020-05-12");
expect(periodProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(docSectionProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(counterpartyProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
@ -1549,6 +1786,10 @@ describe("address filter extraction for balance drilldown", () => {
expect(customerValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(supplierValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(contractValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
expect(vatForecast.warnings).toContain("period_derived_from_month_phrase");
expect(vatForecast.warnings).toContain("period_from_derived_from_quarter_for_vat_forecast");
expect(vatForecast.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
expect(vatForecast.warnings).not.toContain("period_to_defaulted_today_for_management_profile");
});
it("extracts short-year period for lifecycle customer list question", () => {
@ -1560,6 +1801,45 @@ describe("address filter extraction for balance drilldown", () => {
expect(lifecycleShortYear.extracted_filters.period_to).toBe("2020-12-31");
});
it("drops noisy counterparty anchor in ranking question for customer revenue profile", () => {
const extracted = extractAddressFilters(
"какой самый жирный контрагент у нее? кто больше платит денег",
"customer_revenue_and_payments"
);
expect(extracted.extracted_filters.counterparty).toBeUndefined();
expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
});
it("derives VAT forecast quarter-to-date window when plain date phrase is present", () => {
const extracted = extractAddressFilters(
"мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года",
"vat_payable_forecast"
);
expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
expect(extracted.extracted_filters.period_to).toBe("2020-03-15");
expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
});
it("derives VAT forecast quarter-to-date window for explicit day+month+year phrase", () => {
const extracted = extractAddressFilters(
"сколько НДС нужно заплатить за 5 марта 2017 года",
"vat_payable_forecast"
);
expect(extracted.extracted_filters.period_from).toBe("2017-01-01");
expect(extracted.extracted_filters.period_to).toBe("2017-03-05");
expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
});
it("derives VAT forecast quarter-to-date window when strict as-of cue is present", () => {
const extracted = extractAddressFilters(
"сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
"vat_payable_forecast"
);
expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
expect(extracted.extracted_filters.period_to).toBe("2020-03-15");
expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
});
it("defaults as_of_date for documents_forming_balance when date is omitted", () => {
const result = extractAddressFilters("which documents form balance for account 62", "documents_forming_balance");
expect(result.extracted_filters.account).toBe("62");
@ -1633,6 +1913,16 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).toContain("period_window_cleared_for_as_of_intent");
});
it("cuts report-date tail from counterparty anchor and keeps clean as_of filter", () => {
const result = extractAddressFilters(
"Показать незакрытые записи для контрагента 'СВК' на дату отчетности 2020-12-31",
"open_items_by_counterparty_or_contract"
);
expect(result.extracted_filters.counterparty).toBe("СВК");
expect(result.extracted_filters.as_of_date).toBe("2020-12-31");
expect(String(result.extracted_filters.counterparty ?? "").toLowerCase()).not.toContain("отчетности");
});
it("derives month period for balance snapshot from 'на май 2020'", () => {
const result = extractAddressFilters("Какой остаток по счету 60 на май 2020", "account_balance_snapshot");
expect(result.extracted_filters.account).toBe("60");
@ -2042,6 +2332,18 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes top counterparty slang wording into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("какой самый жирный контрагент у нее? кто больше платит денег");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.extracted_filters.counterparty).toBeUndefined();
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes supplier payout question into dedicated aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("кому мы больше всего сгрузили денег, топ-20 поставщиков");
@ -2062,6 +2364,61 @@ describe("address query limited taxonomy and stage diagnostics", () => {
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes top contract wording with 'контракт' into contract value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("contract_usage_and_value");
expect(result?.debug.selected_recipe).toBe("address_contract_usage_and_value_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes revenue-total slang wording into customer value aggregate recipe (no account-missing fallback)", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("скока денег альтернатива заработала за 22 год");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.missing_required_filters).not.toContain("account");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes overall-turnover wording into customer value aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("какие общие обороты за все время");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
});
it("routes VAT payment forecast wording into dedicated VAT forecast recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("какой прогноз оплаты ндс за 12 мая 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_payable_forecast");
expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
expect(result?.debug.extracted_filters.counterparty).toBeUndefined();
});
it("routes colloquial VAT payable estimate wording into VAT forecast recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_payable_forecast");
expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
expect(result?.debug.mcp_call_status).not.toBe("skipped");
});
it("routes customer lifecycle question into dedicated aggregate recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие заказчики работали с нами в 2020 году?");
@ -2137,9 +2494,25 @@ describe("address query limited taxonomy and stage diagnostics", () => {
const service = new AddressQueryService();
const result = await service.tryHandle("покажи документы все по жуковке 51");
expect(result?.handled).toBe(true);
expect(String(result?.assistant_reply ?? "")).not.toContain("Точный якорь не подтвердился");
expect(String(result?.assistant_reply ?? "")).not.toContain("якорь не подтвердился");
if (result?.reply_type === "partial_coverage") {
expect(String(result?.assistant_reply ?? "")).not.toContain("Точный якорь не подтвердился");
expect(result?.debug.rows_matched).toBe(0);
if (String(result?.debug.match_failure_reason ?? "").includes("counterparty_anchor_not_matched")) {
expect(String(result?.assistant_reply ?? "")).toContain("уточните точное имя контрагента");
}
}
});
it("does not keep report-date phrase inside open-items counterparty anchor", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("покажи хвосты по контрагенту СВК на 2020-12-31");
expect(result?.handled).toBe(true);
expect(String(result?.debug.extracted_filters.counterparty ?? "").toLowerCase()).toContain("свк");
expect(String(result?.debug.extracted_filters.counterparty ?? "").toLowerCase()).not.toContain("дата отчетности");
expect(String(result?.debug.anchor_value_raw ?? "").toLowerCase()).not.toContain("дата отчетности");
if (result?.reply_type === "partial_coverage") {
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
}
});
@ -2399,6 +2772,24 @@ describe("address decompose stage follow-up carryover", () => {
).toBe(true);
});
it("replaces 'кроме этого документа...' pseudo-anchor with previous counterparty from follow-up context", () => {
const result = runAddressDecomposeStage("кроме этого документа есть еще чтото?", {
previous_intent: "list_documents_by_counterparty",
previous_filters: {
counterparty: "ТСЖ \\Жуковка 51\\"
},
previous_anchor_type: "counterparty",
previous_anchor_value: "ТСЖ \\Жуковка 51\\"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("list_documents_by_counterparty");
expect(result?.filters.extracted_filters.counterparty).toBe("ТСЖ \\Жуковка 51\\");
expect(
result?.baseReasons?.includes("counterparty_replaced_from_followup_context") ||
result?.baseReasons?.includes("counterparty_from_followup_context")
).toBe(true);
});
it("promotes open-items intent from follow-up wording with inherited contract anchor", () => {
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
previous_intent: "bank_operations_by_contract",
@ -2452,6 +2843,27 @@ describe("address decompose stage follow-up carryover", () => {
expect(followup?.debug.limited_reason_category).not.toBe("missing_anchor");
}
});
it("keeps VAT explain follow-up in address lane and inherits previous period window", () => {
const result = runAddressDecomposeStage("почему прогноз к уплате 0?", {
previous_intent: "vat_payable_forecast",
previous_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.mode.mode).toBe("address_query");
expect(result?.intent.intent).toBe("vat_payable_forecast");
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
expect(
result?.baseReasons?.includes("address_mode_from_followup_context") ||
result?.baseReasons?.includes("intent_from_followup_context")
).toBe(true);
});
});
describe("address recipe catalog counterparty filtering", () => {
@ -2694,5 +3106,24 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПОДОБНО \"60.01%\"");
expect(plan.query).toContain("ПОДОБНО \"60.1%\"");
});
it("builds VAT forecast query with safe account-prefix checks instead of presentation-like clauses", () => {
const filters = extractAddressFilters(
"мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года",
"vat_payable_forecast"
).extracted_filters;
const selected = selectAddressRecipe("vat_payable_forecast", filters);
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 5) = \"68.02\"");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 5) = \"68.02\"");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 4) = \"68.2\"");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 4) = \"68.2\"");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 2) = \"19\"");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 2) = \"19\"");
expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) ПОДОБНО");
expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) ПОДОБНО");
});
});

View File

@ -56,6 +56,26 @@ function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
};
}
function buildAddressLimitedLaneResult(
category: "missing_anchor" | "empty_match" = "missing_anchor",
overrides?: Record<string, unknown>
): any {
const base = buildAddressLaneResult();
return {
...base,
reply_text: "Нужны уточнения по якорю.",
reply_type: "partial_coverage",
response_type: "LIMITED_WITH_REASON",
debug: {
...base.debug,
response_type: "LIMITED_WITH_REASON",
limited_reason_category: category,
reasons: ["address_action_detected", "address_entity_detected"]
},
...(overrides ?? {})
};
}
describe("assistant address follow-up carryover", () => {
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
const calls: Array<{ message: string; options?: any }> = [];
@ -186,13 +206,318 @@ describe("assistant address follow-up carryover", () => {
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(typeof calls[1].options?.followupContext?.previous_anchor_value).toBe("string");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short affirmative 'давай' as follow-up for previous address answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
const followupMessage = "\u0434\u0430\u0432\u0430\u0439";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-davai-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020";
const followupMessage = "показывыай";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult();
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-pokazyvai-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps previous counterparty context for referential follow-up 'кроме этого документа...'", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по жуковке 51";
const followupMessage = "кроме этого документа есть еще чтото?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && !options?.followupContext) {
return null;
}
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
anchor_value_raw: "кроме",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_value_raw: "жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
}
});
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-referential-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(calls).toHaveLength(2);
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "")).toContain("Жуковка 51");
expect(String(calls[1].options?.followupContext?.previous_filters?.counterparty ?? "")).toContain("жуковке 51");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("retries with raw user message after rewrite degraded anchor and returns factual follow-up result", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по жуковке 51";
const followupMessage = "кроме этого документа есть еще чтото?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
const compact = String(message ?? "").trim().toLowerCase();
if (calls.length === 1) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_value_raw: "жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
}
});
}
if (compact === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "жуковке 51"
},
anchor_value_raw: "жуковке 51",
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
if (compact.startsWith("документы по контрагенту") && options?.followupContext) {
return buildAddressLimitedLaneResult("missing_anchor", {
debug: {
...buildAddressLimitedLaneResult("missing_anchor").debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "кроме"
},
anchor_value_raw: "кроме",
anchor_value_resolved: "кроме"
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const sessionId = `asst-address-followup-safe-retry-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
useMock: true
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual");
expect(second.debug?.address_retry_audit?.attempted).toBe(true);
expect(second.debug?.address_retry_audit?.initial_limited_category).toBe("missing_anchor");
expect(second.debug?.address_retry_audit?.retry_message).toBe(followupMessage);
expect(calls.some((entry) => String(entry.message).toLowerCase().startsWith("документы по контрагенту"))).toBe(true);
expect(calls.some((entry) => String(entry.message).toLowerCase() === followupMessage)).toBe(true);
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("reuses last real address context after intermediate clarification fallback", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const lifecycleFollowupMessage = "А кто из них новые?";
@ -279,4 +604,72 @@ describe("assistant address follow-up carryover", () => {
expect(String(contextualCall?.options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
});
it("does not carry address follow-up context into capability question", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "покажи документы по свк за 2020";
const capabilityMessage = "и 1с можешь настроить?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (String(message).toLowerCase().includes("свк")) {
return buildAddressLaneResult();
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService,
chatClient
);
const sessionId = `asst-address-followup-capability-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: firstMessage,
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(first.ok).toBe(true);
expect(first.reply_type).toBe("factual");
const second = await service.handleMessage({
session_id: sessionId,
user_message: capabilityMessage,
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(second.ok).toBe(true);
expect(second.reply_type).toBe("factual_with_explanation");
expect(String(second.assistant_reply).toLowerCase()).toContain("не настраиваю 1с");
expect(calls).toHaveLength(1);
expect(String(calls[0].message).toLowerCase()).toContain("свк");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
});
});

View File

@ -1054,7 +1054,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_canonical_candidate_detected).toBe(true);
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
expect(["llm_canonical_candidate_detected", "address_mode_classifier_detected"]).toContain(response.debug?.tool_gate_reason);
expect(["llm_canonical_candidate_detected", "llm_canonical_data_signal_detected", "address_mode_classifier_detected"]).toContain(response.debug?.tool_gate_reason);
});
it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => {

View File

@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
extractOrganizationFactsFromRowsForTests,
resolveOrganizationNamesByRefsForTests
} from "../src/services/assistantService";
describe("assistant data scope probe parser", () => {
it("extracts organization name and guid from object-ref style row", () => {
const rows = [
{
Organization: {
_objectRef: true,
Guid: "5b516757-45d0-11e1-8c52-001e5848397d",
ObjectType: "CatalogRef.Organization",
Presentation: "OOO Alternativa Plus"
}
}
];
const facts = extractOrganizationFactsFromRowsForTests(rows as any);
expect(facts.names).toContain("OOO Alternativa Plus");
expect(facts.refs).toContain("5b516757-45d0-11e1-8c52-001e5848397d");
expect(
facts.pairs.some(
(item: any) =>
item.ref === "5b516757-45d0-11e1-8c52-001e5848397d" &&
item.name === "OOO Alternativa Plus"
)
).toBe(true);
});
it("extracts guid from hard-key payload (name is best-effort)", () => {
const rows = [
{
payload: {
_objectRef: true,
f1: "5b516757-45d0-11e1-8c52-001e5848397d",
f2: "CatalogRef.Organization",
f3: "OOO Alternativa Plus"
}
}
];
const facts = extractOrganizationFactsFromRowsForTests(rows as any);
expect(facts.refs).toContain("5b516757-45d0-11e1-8c52-001e5848397d");
});
it("resolves names by guid references from ref-name pairs", () => {
const names = resolveOrganizationNamesByRefsForTests(
["5b516757-45d0-11e1-8c52-001e5848397d", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"],
{
names: ["OOO Alternativa Plus", "OOO Drugaya"],
refs: ["5b516757-45d0-11e1-8c52-001e5848397d", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"],
pairs: [
{ ref: "5b516757-45d0-11e1-8c52-001e5848397d", name: "OOO Alternativa Plus" },
{ ref: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", name: "OOO Drugaya" }
]
} as any
);
expect(names).toEqual(["OOO Alternativa Plus"]);
});
});

View File

@ -0,0 +1,792 @@
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
describe("assistant living chat mode", () => {
it("handles casual greeting in chat mode without deep-pipeline pass", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-chat-1",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-1" },
outputText: "Hello. We can chat freely.",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-1",
user_message: "hello, how are you?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("chat");
expect(chatClient.chat).toHaveBeenCalledTimes(1);
expect(normalizer.normalize).toHaveBeenCalledTimes(1);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("does not force address lane for unsupported low-confidence predecompose canonical", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: true,
trace_id: "norm-chat-yo",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: {
schema_version: "normalized_query_v2_0_2",
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "yo",
normalized_fragment_text: "yoft",
confidence: "low",
domain_relevance: "in_scope",
business_scope: "generic_accounting",
time_scope: { type: "missing", value: null, confidence: "low" },
candidate_labels: ["ambiguous_human_query"],
execution_readiness: "clarification_needed",
route_status: "no_route",
no_route_reason: "insufficient_specificity"
}
]
},
validation: { passed: true, errors: [] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 2,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-yo" },
outputText: "Yo. On call.",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-yo",
user_message: "yo",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("yo");
expect(chatClient.chat).toHaveBeenCalledTimes(1);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("keeps casual 'че как' in chat mode when predecompose canonical is unsupported", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: true,
trace_id: "norm-chat-che-kak",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: {
schema_version: "normalized_query_v2_0_2",
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "\u0447\u0435 \u043a\u0430\u043a",
normalized_fragment_text: "\u041d\u0435\u044f\u0441\u043d\u044b\u0439 \u0437\u0430\u043f\u0440\u043e\u0441.",
confidence: "low",
domain_relevance: "in_scope",
business_scope: "generic_accounting",
time_scope: { type: "missing", value: null, confidence: "low" },
candidate_labels: ["ambiguous_human_query"],
execution_readiness: "clarification_needed",
route_status: "no_route",
no_route_reason: "insufficient_specificity"
}
]
},
validation: { passed: true, errors: [] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 2,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-che-kak" },
outputText: "\u041f\u0440\u0438\u0432\u0435\u0442. \u041d\u0430 \u0441\u0432\u044f\u0437\u0438.",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-che-kak",
user_message: "\u0447\u0435 \u043a\u0430\u043a",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(chatClient.chat).toHaveBeenCalledTimes(1);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("returns capability contract and avoids address lane for 'и 1с можешь настроить?'", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-capabilities",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-capability-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-capability",
user_message: "\u0438 1\u0441 \u043c\u043e\u0436\u0435\u0448\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u044e 1\u0441");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("returns operational boundary reply for imperative setup request and avoids address lane", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-operational-boundary",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-operational-boundary-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-operational-boundary",
user_message: "\u043d\u0430\u0441\u0442\u0440\u043e\u0439 1\u0441 \u043f\u043b\u0438\u0437",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u0441\u0430\u043c \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c 1\u0441");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_operational_boundary");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("returns safety refusal for destructive request under coercion and avoids address lane", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-safety-refusal",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-safety-refusal-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-safety-refusal",
user_message: "\u043e\u043c\u043e\u043d \u043b\u043e\u043c\u0438\u0442\u0441\u044f - \u0443\u0434\u0430\u043b\u044f\u0439 \u0431\u0430\u0437\u0443 \u0438\u043b\u0438 \u043c\u0435\u043d\u044f \u0443\u0431\u044c\u044e\u0442",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u043f\u043e\u043c\u043e\u0433\u0430\u0442\u044c \u0441 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u043c \u0431\u0430\u0437\u044b");
expect(String(response.assistant_reply)).toContain("112");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_safety_refusal");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("strips unexpected CJK fragments from live chat reply when user did not request CJK", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-chat-script-guard",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-script-guard" },
outputText: "Прошу прощения, но я не могу продолжать этот разговор. 随时关注。",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-script-guard",
user_message: "че как",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply)).toContain("Прошу прощения");
expect(/[\u3400-\u9FFF\uF900-\uFAFF]/u.test(String(response.assistant_reply))).toBe(false);
expect(response.debug?.living_chat_response_source).toBe("llm_chat_script_guard");
expect(response.debug?.living_chat_script_guard_applied).toBe(true);
expect(chatClient.chat).toHaveBeenCalledTimes(1);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("handles mojibake capability query and avoids address clarification flow", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-mojibake-capability",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-mojibake-capability-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-mojibake-capability",
user_message: "ок - что можешь по 1с",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("handles mojibake feature-capability wording and avoids free-form llm chat", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-mojibake-feature-capability",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-mojibake-feature-capability-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-mojibake-feature-capability",
user_message: "а какие фичи по работе с 1с у тебя отработаны максималльно?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("режиме чтения");
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("handles data-scope meta question as deterministic chat contract", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-data-scope-meta",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-data-scope-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-data-scope",
user_message: "по какой компании мы можем работать?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("mcp-канал");
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("handles 'какая база подрублена?' as deterministic data-scope contract", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-data-scope-podrublena",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-data-scope-podrublena-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-data-scope-podrublena",
user_message: "какая база подрублена?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply).toLowerCase()).toContain("read-only");
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("handles typo data-scope query with misspelled company token as deterministic contract", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-data-scope-typo-company",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-data-scope-typo-company-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-data-scope-typo-company",
user_message: "подскажи плиз с какой компинией можем поработать?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("handles no-question-mark data-scope phrase with interrogative token as deterministic contract", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: "norm-data-scope-no-qmark",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-data-scope-no-qmark-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-data-scope-no-qmark",
user_message: "каза какой компании подключена к 1с",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
expect(chatClient.chat).toHaveBeenCalledTimes(0);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
it("does not misroute contract ranking query to data-scope when canonical text contains 'компании'", async () => {
const normalizer = {
normalize: vi.fn().mockResolvedValue({
ok: true,
trace_id: "norm-no-datascope-contract-ranking",
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
message_in_scope: true,
scope_confidence: "high",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
normalized_fragment_text: "Какой самый крупный договор в истории компании?",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: [],
account_hints: [],
document_hints: ["договор"],
register_hints: [],
time_scope: {
type: "explicit",
value: "all_time",
confidence: "medium"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: true,
asks_for_period_summary: false,
asks_for_rule_check: false,
asks_for_anomaly_scan: false,
asks_for_exact_object_trace: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["simple_factual"],
confidence: "medium",
execution_readiness: "executable",
clarification_reason: null,
soft_assumption_used: [],
route_status: "routed",
no_route_reason: null
}
],
discarded_fragments: [],
global_notes: {
needs_clarification: false,
clarification_reason: null
}
},
validation: { passed: true, errors: [] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
request_count_for_case: 1
})
} as any;
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({
handled: true,
reply_text: "Найден топ-контракт.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_mode_confidence: "high",
query_shape: "DOCUMENT_LIST",
query_shape_confidence: "medium",
detected_intent: "contract_usage_and_value",
detected_intent_confidence: "high",
extracted_filters: { sort: "period_desc", limit: 20 },
missing_required_filters: [],
selected_recipe: "address_contract_usage_and_value_v1",
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: "preferred",
account_scope_fallback_applied: false,
anchor_type: "unknown",
anchor_value_raw: null,
anchor_value_resolved: null,
resolver_confidence: "medium",
ambiguity_count: 0,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: "matched_non_empty",
rows_fetched: 20,
raw_rows_received: 20,
rows_after_account_scope: 20,
rows_after_recipe_filter: 20,
rows_materialized: 20,
rows_matched: 20,
raw_row_keys_sample: [],
materialization_drop_reason: "none",
account_token_raw: null,
account_token_normalized: null,
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
account_scope_drop_reason: "not_applicable",
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: "FACTUAL_LIST",
limitations: [],
reasons: ["contract_usage_and_value_signal_detected"]
}
})
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-should-not-run-for-contract-ranking" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-living-chat-no-datascope-contract-ranking",
user_message: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(response.debug?.tool_gate_reason).not.toBe("assistant_data_scope_query_detected");
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(1);
expect(chatClient.chat).toHaveBeenCalledTimes(0);
});
});

View File

@ -0,0 +1,237 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService";
describe("assistant living router mode decision", () => {
it("returns address_data when address lane already triggered", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "давай",
addressLaneTriggered: true,
useMock: false,
predecomposeMode: "address_query",
predecomposeModeConfidence: "high"
});
expect(decision.mode).toBe("address_data");
expect(decision.reason).toBe("address_lane_triggered");
});
it("keeps deep pipeline in mock mode to avoid test-env network calls", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "привет",
addressLaneTriggered: false,
useMock: true,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("deep_analysis");
expect(decision.reason).toBe("mock_mode_keeps_deep_pipeline");
});
it("routes casual non-data phrase to chat mode", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "привет, как дела?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
});
it("keeps deep mode for strong data signal", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "покажи документы по свк за 2020",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("deep_analysis");
expect(decision.reason).toBe("strong_data_signal_detected");
});
it("routes capability question to chat even when phrase contains 1С", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "и 1с можешь настроить?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_capability_query_detected");
});
it("routes capability question 'ok - what can you do in 1c' to chat", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "\u043e\u043a - \u0447\u0442\u043e \u043c\u043e\u0436\u0435\u0448\u044c \u043f\u043e 1\u0441",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_capability_query_detected");
});
it("routes feature-capability wording to chat", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "а какие фичи по работе с 1с у тебя отработаны максимально?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_capability_query_detected");
});
it("routes data-scope question to chat instead of address lane", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "по какой компании мы можем работать?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_data_scope_query_detected");
});
it("routes 'whose base is this' style question to chat", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "ну база в тебе чья? как называется контора?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_data_scope_query_detected");
});
it("routes 'какая база подрублена?' to data-scope chat mode", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "какая база подрублена?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_data_scope_query_detected");
});
it("routes typo data-scope wording with misspelled company token to chat", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "подскажи плиз с какой компинией можем поработать?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_data_scope_query_detected");
});
it("routes data-scope wording without question mark when interrogative token is present", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "каза какой компании подключена к 1с",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("chat");
expect(decision.reason).toBe("assistant_data_scope_query_detected");
});
it("does not treat contract ranking data query as data-scope meta question", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
predecomposeModeConfidence: "low"
});
expect(decision.mode).toBe("deep_analysis");
expect(decision.reason).toBe("strong_data_signal_detected");
});
});
describe("assistant orchestration contract", () => {
it("keeps VAT payable forecast query in address lane", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "какой прогноз оплаты ндс за 12 мая 2020",
effectiveAddressUserMessage: "какой прогноз оплаты ндс за 12 мая 2020",
followupContext: null,
llmPreDecomposeMeta: null,
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.livingReason).toBe("address_lane_triggered");
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
});
it("keeps supported contract analytics query in address lane", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
effectiveAddressUserMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
followupContext: null,
llmPreDecomposeMeta: null,
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.livingReason).toBe("address_lane_triggered");
});
it("keeps VAT explain follow-up in address lane when followup context is present", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "почему прогноз к уплате 0?",
effectiveAddressUserMessage: "почему прогноз к уплате 0?",
followupContext: {
previous_intent: "vat_payable_forecast",
previous_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
},
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.toolGateReason).toBe("followup_context_detected");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("does not force address lane for deep-analysis unknown intent query with date-like token", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "найди какие либо ошибки на 21 мая 2022 года",
effectiveAddressUserMessage: "Найти ошибки в бухгалтерии за 21 мая 2022 года.",
followupContext: null,
llmPreDecomposeMeta: {
applied: true,
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "deep_analysis",
mode_confidence: "high",
intent: "unknown",
intent_confidence: "low"
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateDecision).toBe("skip_address_lane");
expect(decision.livingMode).toBe("deep_analysis");
expect(["address_signal_unsupported_intent_fallback_to_deep", "no_address_signal_after_l0"]).toContain(
decision.toolGateReason
);
});
});

View File

@ -0,0 +1,140 @@
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
function buildFailedNormalizer(traceId: string) {
return {
normalize: vi.fn().mockResolvedValue({
ok: false,
trace_id: traceId,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
normalized: null,
validation: { passed: false, errors: ["mock"] },
route_hint_summary: null,
raw_model_output: {},
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
latency_ms: 1,
request_count_for_case: 1
})
} as any;
}
function buildHandledAddressLaneMojibakeReply() {
return {
handled: true,
reply_text: "Найдено документов: 5.",
reply_type: "factual",
response_type: "FACTUAL_LIST",
debug: {
detected_mode: "address_query",
detected_mode_confidence: "high",
query_shape: "DOCUMENT_LIST",
query_shape_confidence: "high",
detected_intent: "list_documents_by_counterparty",
detected_intent_confidence: "high",
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "svk"
},
missing_required_filters: [],
selected_recipe: "address_documents_by_counterparty_v1",
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: "preferred",
account_scope_fallback_applied: false,
anchor_type: "counterparty",
anchor_value_raw: "svk",
anchor_value_resolved: "СВК",
resolver_confidence: "high",
ambiguity_count: 0,
match_failure_stage: "none",
match_failure_reason: null,
mcp_call_status: "matched_non_empty",
rows_fetched: 5,
raw_rows_received: 5,
rows_after_account_scope: 5,
rows_after_recipe_filter: 5,
rows_materialized: 5,
rows_matched: 5,
raw_row_keys_sample: [],
materialization_drop_reason: "none",
account_token_raw: null,
account_token_normalized: null,
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
account_scope_drop_reason: "not_applicable",
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: "FACTUAL_LIST",
reasons: ["address_action_detected", "address_entity_detected", "document_list_signal_detected"]
}
} as any;
}
describe("assistant outgoing encoding repair", () => {
it("repairs mojibake in address-lane replies before returning to user", async () => {
const normalizer = buildFailedNormalizer("norm-address-mojibake-out");
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue(buildHandledAddressLaneMojibakeReply())
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-should-not-run" },
outputText: "unused",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-address-mojibake-out",
user_message: "покажи документы по свк за 2020",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(String(response.assistant_reply)).toContain("Найдено документов");
expect(String(response.assistant_reply)).not.toContain("РќР°");
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(1);
expect(chatClient.chat).toHaveBeenCalledTimes(0);
});
it("repairs mojibake in living-chat LLM replies before script guard", async () => {
const normalizer = buildFailedNormalizer("norm-chat-mojibake-out");
const sessions = new AssistantSessionStore();
const addressQueryService = {
tryHandle: vi.fn().mockResolvedValue({ handled: false })
} as any;
const chatClient = {
chat: vi.fn().mockResolvedValue({
raw: { id: "chat-mojibake-out" },
outputText: "Привет! Готов помочь.",
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
})
} as any;
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
const response = await service.handleMessage({
session_id: "asst-chat-mojibake-out",
user_message: "че как",
llmProvider: "local",
model: "qwen2.5",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual_with_explanation");
expect(String(response.assistant_reply)).toContain("Привет");
expect(String(response.assistant_reply)).not.toContain("РџС");
expect(chatClient.chat).toHaveBeenCalledTimes(1);
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
});
});

View File

@ -1,6 +1,23 @@
@echo off
setlocal
cd /d %~dp0
set FEATURE_ASSISTANT_MCP_RUNTIME_V1=1
set ASSISTANT_MCP_PROXY_URL=http://127.0.0.1:6003
set ASSISTANT_MCP_CHANNEL=default
call npm.cmd run dev:all
set "MCP_PROXY_ROOT=%~dp0..\external\1c-mcp-toolkit"
set "MCP_PROXY_PY=%MCP_PROXY_ROOT%\.venv\Scripts\python.exe"
if not exist "%MCP_PROXY_ROOT%" (
echo [WARN] MCP proxy root not found: %MCP_PROXY_ROOT%
) else (
if not exist "%MCP_PROXY_PY%" (
echo [WARN] MCP proxy python not found: %MCP_PROXY_PY%
echo [WARN] Start proxy manually: cd /d "%MCP_PROXY_ROOT%" ^&^& python -m onec_mcp_toolkit_proxy
) else (
echo [INFO] Starting 1C MCP proxy on http://127.0.0.1:6003 ...
start "1C MCP Proxy 6003" cmd /k "cd /d ""%MCP_PROXY_ROOT%"" && set PYTHONUTF8=1 && ""%MCP_PROXY_PY%"" -m onec_mcp_toolkit_proxy"
)
)
call npm.cmd run dev:all:mcp