АДРЕСНЫЙ РЕЖИМ -Step-5 - feat(assistant): стабилизация свободного LLM-роутинга, прическа маршрутов chat/address, прототип прогноза НДС
This commit is contained in:
parent
71762af575
commit
df29798fa2
File diff suppressed because it is too large
Load Diff
|
|
@ -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 по первой группе общего домена (Q1–Q5) с 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
|
||||
|
|
|
|||
|
|
@ -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. Цель и рамки
|
||||
|
||||
Цель этапа: расширять покрытие доменов/интентов без деградации уже стабильного ядра.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 валидации пользовательского запроса и качества ответа.
|
||||
|
|
|
|||
|
|
@ -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. Фактические артефакты закрытия
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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. |
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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. |
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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,8 +1512,12 @@ class AddressQueryService {
|
|||
? "recipe_visibility_gap"
|
||||
: "empty_match";
|
||||
const reasonText = isAnchorMismatch
|
||||
? anchorMismatchCategory === "missing_anchor"
|
||||
? "якорь контрагента/договора не найден в материализованных live-строках"
|
||||
? anchorMismatchByCounterparty
|
||||
? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
|
||||
: anchorMismatchByContract
|
||||
? "договор по указанному номеру/названию не найден в materialized live-строках"
|
||||
: anchorMismatchCategory === "missing_anchor"
|
||||
? "якорь контрагента/договора не найден в materialized live-строках"
|
||||
: "по указанному якорю и фильтрам в live-выборке нет строк"
|
||||
: isRecipeFilteredOut
|
||||
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
|
||||
|
|
@ -1504,7 +1525,11 @@ class AddressQueryService {
|
|||
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
|
||||
: "по выбранным фильтрам в live-выборке нет строк";
|
||||
const nextStep = isAnchorMismatch
|
||||
? anchorMismatchCategory === "missing_anchor"
|
||||
? anchorMismatchByCounterparty
|
||||
? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
|
||||
: anchorMismatchByContract
|
||||
? `уточните номер/наименование договора${requestedPeriodHint}`
|
||||
: anchorMismatchCategory === "missing_anchor"
|
||||
? "уточните контрагента точным именем или добавьте ИНН/договор"
|
||||
: "уточните период или снимите часть фильтров"
|
||||
: isRecipeFilteredOut
|
||||
|
|
@ -1514,7 +1539,11 @@ class AddressQueryService {
|
|||
: "уточните период, контрагента, договор или снимите часть фильтров";
|
||||
const limitations = isAnchorMismatch
|
||||
? [
|
||||
anchorMismatchCategory === "missing_anchor"
|
||||
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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,8 +476,19 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
|||
}
|
||||
function runAddressDecomposeStage(userMessage, followupContext) {
|
||||
const detectedMode = (0, addressQueryClassifier_1.detectAddressQuestionMode)(userMessage);
|
||||
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",
|
||||
|
|
@ -451,11 +499,6 @@ function runAddressDecomposeStage(userMessage, followupContext) {
|
|||
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 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
|
|
@ -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 : [];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,8 +1804,12 @@ export class AddressQueryService {
|
|||
? "recipe_visibility_gap"
|
||||
: "empty_match";
|
||||
const reasonText = isAnchorMismatch
|
||||
? anchorMismatchCategory === "missing_anchor"
|
||||
? "якорь контрагента/договора не найден в материализованных live-строках"
|
||||
? anchorMismatchByCounterparty
|
||||
? "контрагент по указанному имени/алиасу не найден в materialized live-строках"
|
||||
: anchorMismatchByContract
|
||||
? "договор по указанному номеру/названию не найден в materialized live-строках"
|
||||
: anchorMismatchCategory === "missing_anchor"
|
||||
? "якорь контрагента/договора не найден в materialized live-строках"
|
||||
: "по указанному якорю и фильтрам в live-выборке нет строк"
|
||||
: isRecipeFilteredOut
|
||||
? "строки по якорю найдены, но отфильтрованы intent-specific recipe"
|
||||
|
|
@ -1773,7 +1817,11 @@ export class AddressQueryService {
|
|||
? "в текущем live recipe нет достаточной document/bank видимости после фильтрации"
|
||||
: "по выбранным фильтрам в live-выборке нет строк";
|
||||
const nextStep = isAnchorMismatch
|
||||
? anchorMismatchCategory === "missing_anchor"
|
||||
? anchorMismatchByCounterparty
|
||||
? `уточните точное имя контрагента или добавьте ИНН${requestedPeriodHint}`
|
||||
: anchorMismatchByContract
|
||||
? `уточните номер/наименование договора${requestedPeriodHint}`
|
||||
: anchorMismatchCategory === "missing_anchor"
|
||||
? "уточните контрагента точным именем или добавьте ИНН/договор"
|
||||
: "уточните период или снимите часть фильтров"
|
||||
: isRecipeFilteredOut
|
||||
|
|
@ -1783,7 +1831,11 @@ export class AddressQueryService {
|
|||
: "уточните период, контрагента, договор или снимите часть фильтров";
|
||||
const limitations = isAnchorMismatch
|
||||
? [
|
||||
anchorMismatchCategory === "missing_anchor"
|
||||
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"
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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}`
|
||||
|
|
@ -1064,11 +1181,10 @@ export function composeFactualReply(
|
|||
|
||||
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}`)
|
||||
);
|
||||
|
|
@ -1080,11 +1196,10 @@ export function composeFactualReply(
|
|||
|
||||
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} операций не найдено.`);
|
||||
} else {
|
||||
|
|
@ -1103,11 +1218,10 @@ export function composeFactualReply(
|
|||
|
||||
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}`
|
||||
|
|
@ -1121,11 +1235,10 @@ export function composeFactualReply(
|
|||
|
||||
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 операции).");
|
||||
}
|
||||
|
|
@ -1141,11 +1254,10 @@ export function composeFactualReply(
|
|||
}
|
||||
|
||||
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";
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 : [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
if (result?.reply_type === "partial_coverage") {
|
||||
expect(String(result?.assistant_reply ?? "")).not.toContain("Точный якорь не подтвердился");
|
||||
expect(String(result?.assistant_reply ?? "")).not.toContain("якорь не подтвердился");
|
||||
if (result?.reply_type === "partial_coverage") {
|
||||
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("ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) ПОДОБНО");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue