Compare commits

...

18 Commits

Author SHA1 Message Date
dctouch f7314b0212 Исправить компактные follow-up ответы по денежному итогу 1С 2026-05-23 17:30:01 +03:00
dctouch 91a9dfa3a7 Исправить компактный денежный ответ без топов 1С 2026-05-23 16:13:45 +03:00
dctouch 5f7e201190 Исправить прямой ответ о чистой прибыли 1С 2026-05-23 15:34:11 +03:00
dctouch a15f24f21d Стабилизировать маржинальность номенклатуры 1С 2026-05-23 14:39:37 +03:00
dctouch 473cdc3a9b Стабилизировать денежные ответы и прибыль 1С-ассистента 2026-05-23 13:14:22 +03:00
dctouch b18613961c Игнорировать временные runtime-артефакты 1С-ассистента 2026-05-23 10:35:48 +03:00
dctouch e38223e33e Закрепить расширенный AGENT-прогон качества 1С-ассистента 2026-05-23 09:07:24 +03:00
dctouch 50d938b8f1 Закрепить целевой AGENT-прогон hot value-flow handoff 2026-05-22 23:00:37 +03:00
dctouch e7603a9d29 Протянуть hot handoff в discovery response 2026-05-22 19:33:02 +03:00
dctouch 5125c747d8 Добавить handoff допуска для автономного value-flow 2026-05-22 19:11:16 +03:00
dctouch d64d7f84cc Связать evidence planner с route candidate handoff 2026-05-22 18:48:51 +03:00
dctouch f34ae5d6df Добавить evidence planner для автономного маршрута 2026-05-22 18:25:05 +03:00
dctouch 0bd631c160 Принять agent replay маржинальности номенклатуры 2026-05-22 16:21:59 +03:00
dctouch 09c6d1aa0e Стабилизировать маржинальность номенклатуры 2026-05-22 16:17:07 +03:00
dctouch f5d86d4bc1 Дочистить user-facing ответы 1С ассистента 2026-05-22 10:07:17 +03:00
dctouch 1a94e72381 Сжать долговые ответы и техничность net-flow 2026-05-22 09:05:58 +03:00
dctouch 151f2a26de Усилить бизнес-ответы 1С ассистента после прогона 105 2026-05-21 10:22:51 +03:00
dctouch bbc257fd6c Усилить answer contract и агентный аудит для phase105 2026-05-21 09:00:08 +03:00
129 changed files with 10500 additions and 879 deletions

4
.gitignore vendored
View File

@ -30,3 +30,7 @@ graphify-out/
artifacts/domain_runs/*
!artifacts/domain_runs/.gitkeep
artifacts/runtime_logs/*.log
artifacts/runtime/
# generated assistant-stage runtime eval jobs
llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-*.json

View File

@ -44,6 +44,27 @@ Fresh validation cut:
- `npm.cmd run build` passed;
- graphify rebuilt to `6371` nodes, `14048` edges, `141` communities.
## 2026-05-18 Overlay - Context Entry And Latest Semantic Integrity Closure
The current short handoff document is now `41 - assistant_context_entry_2026-05-18.md`.
The latest saved-session semantic replay closure is:
- source saved session: `gen-mo1t93wq-jy0453e`;
- final replay artifact: `artifacts/domain_runs/saved_session_gen_mo1t93wq_jy0453e_rerun_final_semantic_20260518`;
- result: `accepted`, `31/31 passed`, `0 failed`, `execution_status=exact`;
- commit: `9c86407 Зафиксировать семантическую целостность VAT, debt mirror и trace-ответов`;
- graphify after the code cut: `6490 nodes`, `14412 edges`, `141 communities`.
This May-18 closure does not reopen Post-F. It reinforces Post-F as a regression gate and records four extra live-session seams:
- same-period VAT follow-up now preserves the prior requested tax period instead of drifting to current date;
- stale MCP discovery counterparty no longer contaminates short debt mirror follow-ups such as `а нам?`;
- inventory sale trace answers now distinguish sale trace by nomenclature from exact selected lot/batch proof;
- broad best-year answers no longer rank unreliable yearly operating net when one direction is row-limit constrained.
Use this overlay when starting a new chat or preparing a Tasker handoff card.
## Current Module Map
- `Post-F Semantic Integrity Hardening`: `99%`, operationally closed as a hardening slice and now used as a regression gate.
@ -96,6 +117,7 @@ Fresh validation cut:
- Completed broader schema/primitive discovery closure slice: `Mixed Schema/Primitive Closure Replay`: phase105 validates the combined current module surface across inventory root scope, historical inventory carryover, role-tail hygiene, bank role/purpose, supplier payout, bidirectional SVK value-flow, clean debt polarity, VAT tax-period continuity, and cash-flow/profit boundary; phase105 live replay is accepted.
- Current live canary: `phase105_mixed_schema_primitive_closure_live3` accepted `13/13`.
- Current accepted autorun: `AGENT | Phase 105 mixed schema/primitive closure replay` (`gen-ag05131312-2d0445`).
- Current saved-session semantic replay closure: `saved_session_gen_mo1t93wq_jy0453e_rerun_final_semantic_20260518` accepted `31/31`.
- Implementation breadth: `~99% (Open-World Bounded Autonomy Breadth through Slice 25)`.
- Active broader autonomy module: `Open-World Schema/Primitive Discovery`, with phases97-105 accepted and saved; the module is now at manual-review readiness rather than another blind coding slice.
- Next active slice: run/review the phase105 GUI autorun or the user's fat manual pack; if it stays clean, close this module, otherwise convert the next observed failure into a narrow phase106 repair/replay.
@ -167,28 +189,29 @@ After any code or documentation sync that changes the map, rebuild graphify and
For current planning, read:
1. `README.md`
2. this document
3. `31 - inventory_reserve_liquidation_quality_reviewed_route_2026-05-12.md`
4. `33 - limit_honesty_business_language_2026-05-13.md`
5. `32 - financial_counterparty_flow_hints_2026-05-13.md`
6. `30 - vendor_procurement_quality_reviewed_route_2026-05-12.md`
7. `29 - debt_due_date_aging_reviewed_route_2026-05-10.md`
8. `28 - accounting_profit_margin_reviewed_route_2026-05-10.md`
9. `27 - proof_family_enablement_candidates_2026-05-10.md`
10. `26 - route_candidate_driven_enablement_loop_2026-05-10.md`
11. `25 - open_world_route_candidate_promotion_2026-05-10.md`
12. `34 - large_query_budget_continuation_2026-05-13.md`
13. `35 - large_query_continuation_ux_2026-05-13.md`
14. `36 - inventory_root_scope_no_warehouse_clarification_2026-05-13.md`
15. `37 - debt_mirror_clean_scope_polarity_2026-05-13.md`
16. `24 - agentic_semantic_development_loop_and_autorun_hygiene_2026-05-10.md`
17. `23 - current_execution_spine_and_semantic_control_gate_2026-05-05.md`
18. `22 - open_world_bounded_autonomy_breadth_2026-05-01.md`
19. `20 - planner_autonomy_consolidation_2026-05-01.md`
20. `19 - inventory_stock_open_world_breadth_proof_2026-05-01.md`
21. `40 - mixed_schema_primitive_closure_replay_2026-05-13.md`
22. `39 - generic_role_tail_anchor_hygiene_2026-05-13.md`
23. `17 - post_f_semantic_integrity_hardening_2026-04-23.md`
24. `16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md`
2. `41 - assistant_context_entry_2026-05-18.md`
3. this document
4. `31 - inventory_reserve_liquidation_quality_reviewed_route_2026-05-12.md`
5. `33 - limit_honesty_business_language_2026-05-13.md`
6. `32 - financial_counterparty_flow_hints_2026-05-13.md`
7. `30 - vendor_procurement_quality_reviewed_route_2026-05-12.md`
8. `29 - debt_due_date_aging_reviewed_route_2026-05-10.md`
9. `28 - accounting_profit_margin_reviewed_route_2026-05-10.md`
10. `27 - proof_family_enablement_candidates_2026-05-10.md`
11. `26 - route_candidate_driven_enablement_loop_2026-05-10.md`
12. `25 - open_world_route_candidate_promotion_2026-05-10.md`
13. `34 - large_query_budget_continuation_2026-05-13.md`
14. `35 - large_query_continuation_ux_2026-05-13.md`
15. `36 - inventory_root_scope_no_warehouse_clarification_2026-05-13.md`
16. `37 - debt_mirror_clean_scope_polarity_2026-05-13.md`
17. `24 - agentic_semantic_development_loop_and_autorun_hygiene_2026-05-10.md`
18. `23 - current_execution_spine_and_semantic_control_gate_2026-05-05.md`
19. `22 - open_world_bounded_autonomy_breadth_2026-05-01.md`
20. `20 - planner_autonomy_consolidation_2026-05-01.md`
21. `19 - inventory_stock_open_world_breadth_proof_2026-05-01.md`
22. `40 - mixed_schema_primitive_closure_replay_2026-05-13.md`
23. `39 - generic_role_tail_anchor_hygiene_2026-05-13.md`
24. `17 - post_f_semantic_integrity_hardening_2026-04-23.md`
25. `16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md`
Documents `01` through `15` remain valuable, but mostly as the historical architecture trail.

View File

@ -0,0 +1,178 @@
# 41 - Вход в контекст разработки ассистента 1C (2026-05-18)
## Назначение
Этот документ является короткой, но полной точкой входа для нового чата, нового Codex-сеанса или нового инженера.
Он фиксирует актуальное состояние архитектуры после закрытия последнего semantic replay по saved-session `gen-mo1t93wq-jy0453e` и коммита:
- `9c86407 Зафиксировать семантическую целостность VAT, debt mirror и trace-ответов`
Главная цель проекта не изменилась:
- построить 1C-ассистента, который сам выбирает безопасные маршруты по MCP/1C evidence;
- не превращать систему в набор хардкодных доменных скрепок;
- отвечать бизнесово полезно, прямо и честно;
- не выдавать неподтвержденные inference как confirmed 1C fact.
## Что читать первым
Для быстрого входа читать в таком порядке:
1. `AGENTS.md`
2. `graphify-out/GRAPH_REPORT.md`
3. `docs/ARCH/11 - architecture_turnaround/README.md`
4. `docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md`
5. `docs/ARCH/11 - architecture_turnaround/24 - agentic_semantic_development_loop_and_autorun_hygiene_2026-05-10.md`
6. `docs/ARCH/11 - architecture_turnaround/40 - mixed_schema_primitive_closure_replay_2026-05-13.md`
7. этот документ
8. актуальные Tasker-карточки с label `1C assistant` и `Архитектура`
Исторические документы `01`-`20` важны как trail решений, но текущий рабочий статус берется из `21`, `24`, `40`, этого документа и Tasker.
## Текущий статус модулей
Post-F Semantic Integrity Hardening:
- статус: `99%`, operationally closed;
- теперь это regression gate, а не активный denominator;
- защищает stale scope, wrong focus_object, repeated pivots, post-pivot arbitration, VAT materialization, debt mirror polarity, selected-object continuity и answer-shape truth.
Planner Autonomy Consolidation:
- статус: `100%` для declared phase83 slice;
- planner-brain, catalog alignment, live-readiness gate и mixed replay приняты;
- это не означает arbitrary 1C autonomy, но это закрывает первый мозг маршрутов.
Open-World Schema/Primitive Discovery:
- phases97-105 приняты и сохранены как canaries;
- phase105 mixed schema/primitive closure replay accepted `13/13`;
- модуль находится на manual-review readiness, а не на blind coding stage.
Agentic Semantic Development Loop:
- статус: `99%`;
- stage loop, business-audit handoff, save-after-acceptance gate и autorun hygiene работают;
- human GUI checkpoint остается финальным high-signal подтверждением.
Последний semantic integrity cut:
- saved session: `gen-mo1t93wq-jy0453e`;
- final replay: `artifacts/domain_runs/saved_session_gen_mo1t93wq_jy0453e_rerun_final_semantic_20260518`;
- результат: `accepted`, `31/31 passed`, `0 failed`, `execution_status=exact`;
- закрытые seams: VAT same-period carryover, stale MCP discovery counterparty in short debt mirror, sale-trace lot/batch honesty, broad best-year net-ranking honesty.
## Архитектурная концепция
Система состоит из нескольких связанных слоев.
Assistant runtime:
- внешний фасад живого ассистента, GUI и runtime-сессий;
- основной pressure center все еще вокруг `assistantService.ts`, но бизнесовая логика постепенно вынесена в специализированные модули;
- runtime обязан сохранять session state, но не должен позволять старой памяти победить explicit current-turn meaning.
Exact address lane:
- `AddressQueryService`, `addressRecipeCatalog`, `addressIntentResolver`, `addressFilterExtractor`, `address_runtime/*`;
- отвечает за VAT, receivables/payables, inventory, value-flow, bank operations, accounting result, procurement, debt aging, inventory quality events и связанные factual replies;
- exact route может быть fast path, но не может обходить truth gate, scope gate и answer-shape gate.
MCP discovery/planner lane:
- `assistantMcpDiscoveryPlanner`, `assistantMcpCatalogIndex`, `assistantMcpDiscoveryPilotExecutor`, `assistantMcpDiscoveryRuntimeBridge`;
- выбирает reviewed primitive chains через metadata, entity grounding, documents, movements, value-flow, route candidates;
- не должен превращать unknown или proxy-only evidence в confirmed fact.
Data-need graph and route-candidate layer:
- описывает вопрос пользователя как бизнесовую потребность, а не только как route id;
- хранит subject, fact family, action family, period, aggregation, ranking, comparison, proof expectation, missing axes и forbidden-overclaim flags;
- `route_candidate` превращает missing proof family в конкретный enablement target, а не в размытое “не умеем”.
Continuity and transition layer:
- `assistantTransitionPolicy`, `assistantContinuityPolicy`, `assistantMcpDiscoveryTurnInputAdapter`, navigation state и focus/answer object helpers;
- решает, что переносится между turn-ами: organization, counterparty, period, selected object, answer_object, provenance bundle;
- explicit current-turn entity/period/action должны побеждать stale organization, stale focus_object и старые discovery candidates.
Answer shaping and response policy:
- `assistantMcpDiscoveryAnswerAdapter`, `assistantMcpDiscoveryResponseCandidate`, `assistantMcpDiscoveryResponsePolicy`, factual reply builders;
- пользовательский ответ должен начинаться с прямого business answer;
- proof, caveats, row limits и method notes идут после ответа;
- internal route ids, capability ids, raw debug enums и service mechanics не должны попадать в финальный ответ.
GUI, autoruns and runtime artifacts:
- `autoRuns`, `eval`, `assistantService`, `addressTextRepair`;
- GUI autoruns являются human checkpoint и replay history;
- сохранение вопросов не равно AGENT replay;
- saved AGENT pack допустим только после live replay and review;
- UTF-8 без BOM и отсутствие mojibake являются acceptance surface, а не косметикой.
## Главные инварианты
- Сначала проверяется человеческий смысл вопроса и ответа, потом debug.
- Live replay важнее зеленых unit tests.
- Explicit текущий субъект сильнее stale scope.
- Valid clarification is not a bug.
- Debt mirror должен различать `мы должны` и `нам должны`.
- Bank-like counterparty не является обычным поставщиком или клиентом без purpose/operation evidence.
- VAT period carryover должен сохранять тот же период, если пользователь говорит `за этот период`.
- Inventory sale trace может подтвердить sale trace by nomenclature, но не exact selected lot без batch/lot proof.
- Broad business overview не должен ранжировать unreliable net, если один из потоков ограничен row cap.
- No route/proxy/MCP/debug garbage in final answer.
## Текущие canary/replay anchors
Ключевые canaries:
- `phase83_planner_brain_alignment_live_20260501_readygate_rerun3`
- `address_truth_harness_post_f_cross_stage_canary_agent_20260424_live7`
- `inventory_stock_open_world_breadth_rerun_semantic_integrity_20260501_fix5`
- `phase90`-`phase96` route-candidate/proof-family acceptance chain
- `phase97_financial_counterparty_flow_hints_live4`
- `phase98_limit_honesty_business_language_live3`
- `phase99_large_query_budget_continuation_live2`
- `phase100_large_query_continuation_ux_live2`
- `phase101_inventory_root_scope_no_warehouse_clarification_live1`
- `phase102_debt_mirror_clean_scope_polarity_live3`
- `phase103_financial_role_purpose_arbitration_live3`
- `phase104_generic_role_tail_anchor_hygiene`
- `phase105_mixed_schema_primitive_closure_live3`
- `saved_session_gen_mo1t93wq_jy0453e_rerun_final_semantic_20260518`
Если новый фикс касается соседней зоны, соответствующий replay нужно использовать как semantic regression gate.
## Как продолжать в новом чате
Новый Codex-сеанс должен начинать так:
1. прочитать `AGENTS.md`;
2. прочитать `graphify-out/GRAPH_REPORT.md`;
3. прочитать этот документ, README, `21`, `24`, `40`;
4. проверить `git status --short`;
5. не коммитить runtime_job artifacts без явного решения;
6. определить текущий active module и его denominator;
7. если пользователь принес run id, сначала читать human Q/A, затем artifacts/debug;
8. если внесены code changes, запускать targeted tests, build по необходимости, semantic replay, graphify rebuild;
9. после accepted replay обновлять docs/Tasker, если меняется архитектурный статус.
## Текущее направление после этого среза
Следующий крупный фокус:
- закрепить agentic semantic replay loop как регулярный gate после крупных фиксов;
- минимизировать ручную роль пользователя до финального GUI checkpoint;
- продолжать движение к open-world bounded autonomy, где ассистент не ждет хардкодного route-per-domain, а выбирает reviewed MCP primitive path по data-need graph, route_candidate и evidence gates.
При этом нельзя объявлять проект universal arbitrary-1C agent.
Честная формулировка текущего состояния:
- система уже умеет много устойчивых 1C-контуров;
- bounded MCP autonomy substrate реально работает;
- route-candidate-driven enablement показал, как превращать gaps в reviewed routes;
- но широкая arbitrary schema traversal все еще должна расширяться через replay-backed slices, а не через свободную импровизацию.

View File

@ -0,0 +1,175 @@
# 42 - Project Audit Milestone And Next Vector (2026-05-18)
## Purpose
This note records the May-18 audit milestone after a repo-first review of:
- current runtime code shape;
- graphify pressure centers;
- current architecture canon in `21`, `24`, `40`, and `41`;
- Tasker handoff cards for `1C assistant` / `1С-ассистент`;
- latest accepted live replay and saved-session closure artifacts.
Its purpose is not to reopen old slices.
Its purpose is to:
- freeze the current project map in one place;
- state what is actually closed, what is still open, and what is merely pressure-tested;
- define the next unified development vector after the current sync pass.
## Audit Result
The project is materially beyond the old "build first exact routes" stage.
The real current architecture is a bounded MCP-first assistant built from these active layers:
- assistant runtime and orchestration facade;
- exact address lane;
- MCP discovery / planner lane;
- data-need graph and route-candidate layer;
- continuity / transition / selected-object state layer;
- truth gate and answer-shaping layer;
- AGENT semantic replay operating loop;
- GUI / autorun / runtime artifact layer.
The most connected code pressure centers remain:
- `executeAssistantMcpDiscoveryPilot()`
- `buildAssistantMcpDiscoveryTurnInput()`
- `ChannelRegistry`
- `composeFactualReplyBody()`
- `extractAddressFilters()`
- `repairAddressMojibake()`
This confirms that the current blast radius is not only in one route or one wording family. The highest-risk seams still sit at:
- runtime input assembly;
- continuity and stale-scope arbitration;
- MCP execution and evidence shaping;
- final answer truthfulness and text hygiene.
## Current Module Map
### Operationally closed or used as regression gates
- `Post-F Semantic Integrity Hardening`: operationally closed at `99%`
- `Planner Autonomy Consolidation`: closed at `100%`
- `Route-Candidate-Driven Enablement Loop`: closed at `100%`
- reviewed proof-family routes through phases `93-96`
- business-overview breadth slices through the currently accepted reviewed families
### Active but already implementation-heavy
- `Open-World Schema/Primitive Discovery`: `95%`
- phases `97-105` are accepted and saved
- current closure replay: `phase105_mixed_schema_primitive_closure_live3`, `13/13`
- module status: manual-review readiness, not blind coding stage
### Active operating layer
- `Agentic Semantic Development Loop`: `99%`
- dogfood loop accepted
- autorun/runtime Cyrillic hygiene accepted
- manual GUI confirmation still required before treating fat packs as fully human-accepted
## Latest Accepted Semantic Closure
Latest saved-session closure:
- source saved session: `gen-mo1t93wq-jy0453e`
- replay artifact: `artifacts/domain_runs/saved_session_gen_mo1t93wq_jy0453e_rerun_final_semantic_20260518`
- result: `accepted`
- score: `31/31 passed`
- execution status: `exact`
- commit anchor: `9c86407 Зафиксировать семантическую целостность VAT, debt mirror и trace-ответов`
This closure additionally hardened:
- same-period VAT carryover;
- stale MCP discovery counterparty contamination in short debt-mirror follow-ups;
- sale-trace honesty between nomenclature trace and exact lot/batch proof;
- broad best-year net-ranking honesty under row-cap asymmetry.
## Important Accepted Breadth Boundary
`phase86` must be treated as a real accepted contour in the current map:
- spec: `docs/orchestration/address_truth_harness_phase86_business_overview_debt_position.json`
- accepted replay: `artifacts/domain_runs/address_truth_harness_phase86_business_overview_debt_position_live_20260504_debt2`
Meaning:
- explicit-period `business_overview` may include confirmed receivables/payables snapshot on an explicit as-of date;
- all-time follow-up must not reuse the old debt snapshot as current or general debt position;
- debt quality, overdue debt, credit risk, profit, and margin remain bounded unless separately proven.
This boundary matters because it is a clean example of the current project style:
- expand breadth through reviewed evidence;
- widen answer usefulness;
- keep stale-scope and overclaim protection intact.
## Documentation And Tasker Audit Findings
### What was already good
- the repo canon in `21`, `24`, `40`, and `41` is coherent and materially up to date;
- the high-level Tasker card set `01-17` already covers the architecture spine well;
- the dedicated context-entry card `[1С-ассистент] Вход в контекст разработки ассистента 1С` already works as the right top-level handoff card.
### What was missing or underrepresented
- latest May-18 saved-session closure was not reflected everywhere;
- the current honest `95%` status for `Open-World Schema/Primitive Discovery` needed to be stated more explicitly in Tasker;
- the accepted `phase86` debt-position breadth boundary was present in repo docs and artifacts, but was not explicit enough in the card layer;
- the practical review order `human Q/A first -> replay artifacts -> debug later` needed to be restated in the main handoff card layer;
- the current "active gate" reality around semantic-control / fat GUI review was still more explicit in repo docs than in the Tasker map.
## Unified Next Development Vector
The project should not fork into unrelated planning threads.
The current unified vector is:
1. Close the current human acceptance gate cleanly.
2. Institutionalize the AGENT semantic replay loop as the normal post-fix gate.
3. Expand open-world bounded autonomy only through replay-backed breadth slices.
4. Reduce pressure on central continuity and intent seams while preserving existing regression gates.
In practical terms this means:
- first finish `phase105` GUI/manual review or the equivalent fat user pack review;
- if the review is clean, close the current `Open-World Schema/Primitive Discovery` module honestly;
- if the review exposes a real semantic defect, convert it into a narrow `phase106 repair/replay`, not a broad architecture rewrite;
- continue future breadth through reviewed primitive descriptors, data-need coverage, route-candidate enablement, and replay-backed acceptance;
- do not regress into domain-hardcode-first growth.
## What Should Not Become The Next Vector
The next vector should not be:
- "rewrite the runtime again";
- "add routes opportunistically without replay discipline";
- "treat green tests or route ids as primary acceptance";
- "expand arbitrary schema traversal before the current closure gate is honestly reviewed";
- "replace the AGENT loop with ad hoc manual debugging."
## Recommended Immediate Work Order
1. Keep the main context-entry card and current module cards synchronized with repo canon.
2. Add or maintain one explicit Tasker card for the current semantic-control / GUI/manual acceptance gate if that gate is not clearly represented.
3. Use `phase105` and the May-18 saved-session closure as the current top replay anchors.
4. After the card sync, plan the next slice only from the audited state above, not from older percentage snapshots or stale docs.
## Honest Project Status Statement
The project already has a real bounded MCP autonomy substrate and a strong semantic hardening backbone.
It is not a universal arbitrary-1C agent yet.
The correct current status is:
- many important 1C contours are already stable and replay-backed;
- the main remaining risk is not absence of architecture, but semantic drift on mixed human-style pressure surfaces;
- the next development denominator is controlled breadth expansion after an honest current acceptance closure, not a reset.

View File

@ -0,0 +1,360 @@
# 43 - Business Answer Contract And Semantic Audit Uplift (2026-05-18)
## Purpose
This note freezes the next real project module after the May-18 project audit.
Its purpose is to turn a high-quality human audit of real runs into:
- a runtime answer-shaping module;
- a stronger semantic replay audit contract;
- a concrete execution order for the next development stage.
The core conclusion is:
- the assistant already finds many real facts correctly;
- the current weak point is not raw retrieval;
- the weak point is the conversion from confirmed facts into a business-useful answer and a business-grade replay verdict.
In short:
`truth/retrieval is materially ahead of user-facing answer quality and semantic audit quality`
## Canonical Diagnosis
The current assistant too often behaves like a careful technical reporter instead of a strong 1C business analyst.
This means:
- confirmed numbers are found;
- organization/date context is often preserved;
- truth and anti-overclaim guards are active;
- but the final answer is still overloaded with caveats, low on management interpretation, weak on next action, and not stable enough on semantic disambiguation.
The current failure pattern is:
1. the system proves a fact;
2. the truth layer leaks directly into the user answer;
3. the user receives a cautious technical block instead of a business answer;
4. the replay evaluator notices this only partially;
5. the run remains technically respectable but business-weak.
## What The Human Audit Proved
The human audit established two linked facts.
### 1. The current audit layer is too weak
If the AGENT semantic replay audit were as strong as the human review, more runs would be rejected or repaired before being treated as healthy.
The current replay stack can already see some of this through metrics such as:
- `accountant_actionability_score`
- `mechanism_specificity_score`
- `followup_context_retention_score`
But the practical business reading is still underpowered.
The system can tell that the answer is weak.
It is still not good enough at explaining why it is weak in the same business language a strong human reviewer uses.
### 2. The runtime answer layer is the main product gap
The main user-facing problem is not "the system cannot compute".
The main problem is:
`the system cannot consistently convert computed truths into a concise, useful, accountant-grade answer`
This gap shows up as:
- dirty answer shape;
- too much defensive wording in the main body;
- weak distinction between cashflow, profit, debt, bank contour, inventory snapshot, and historical inventory;
- weak business takeaway;
- missing next action;
- technical or truth-gate wording leaking into the user-facing answer.
## Architectural Reading Of The Gap
This audit should be read as a split between two layers.
### Layer A: Truth Layer
This layer answers:
- what is confirmed;
- by which evidence;
- within which date window;
- with which limitations;
- whether overclaim protection should block or soften the answer.
This layer is already substantial and should remain strict.
### Layer B: Business Answer Layer
This layer answers:
- what the result means for the business user;
- which figures matter first;
- what ambiguity should be surfaced explicitly;
- what should be checked next;
- how to keep the answer readable without lying.
This layer is the main missing product denominator.
Current problem:
`Layer A is too visible; Layer B is too weak`
## Exact Code Seams
The current implementation seams match this diagnosis closely.
### Runtime answer shaping seams
- `llm_normalizer/backend/src/services/answerComposer.ts`
- `llm_normalizer/backend/src/services/address_runtime/composeStage.ts`
- `llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts`
- `llm_normalizer/backend/src/services/assistantDebugPayloadAssembler.ts`
Relevant active contracts already exist:
- `answer_contract_stage4_v1`
- `answer_structure_v11`
- `direct_answer`
- `mechanism_block`
- `uncertainty_block`
- `next_step_block`
This means the next step is not a greenfield rewrite.
It is a targeted strengthening of the answer contract and reply renderer.
### Replay / audit seams
- `llm_normalizer/backend/src/services/evalService.ts`
- `llm_normalizer/backend/src/eval/p0_eval_runner.ts`
- `llm_normalizer/backend/src/eval/p0_metric_definitions.ts`
- `llm_normalizer/backend/src/eval/p0_acceptance_gate.ts`
These seams already compute weak/strong answer signals, but they do not yet encode the full business-grade audit logic demonstrated by the human review.
## Next Module Definition
The next module should be named:
`Business Answer Contract + Semantic Audit Uplift`
It must be treated as one module with two subtracks, not as unrelated work.
### Track 1. Runtime Business Answer Contract
Goal:
- make the assistant answer like a usable 1C analyst without weakening truthfulness.
Required outcomes:
1. Introduce stable answer shapes for business question families.
2. Move caveats out of the main answer body and compress them into one honest boundary block.
3. Force a management-first direct answer before evidence details.
4. Always provide a concrete next action when the contour allows it.
5. Prevent technical leakage into the user-facing answer.
### Track 2. Semantic Audit Uplift
Goal:
- make AGENT replay review detect the same answer-quality failures that a strong human auditor detects.
Required outcomes:
1. Detect when the answer is factually grounded but business-useless.
2. Detect when ambiguity was handled weakly or silently.
3. Detect when caveats dominate the main answer instead of being bounded cleanly.
4. Detect when heterogeneous quantities are surfaced as misleading business totals.
5. Detect when the answer lacks a management takeaway or next step.
## Required Runtime Changes
### 1. Introduce answer intent shapes
The runtime should stop relying only on broad reply classes such as:
- `factual`
- `factual_with_explanation`
- `partial_coverage`
- `clarification_required`
It should add stable business answer shapes such as:
- `inventory_snapshot`
- `historical_inventory_snapshot`
- `cashflow_overview`
- `profit_vs_cashflow_disambiguation`
- `counterparty_value_flow`
- `bank_flow_classification`
- `tax_payable`
- `accounts_payable`
- `accounts_receivable`
- `business_overview_summary`
Each shape should have its own user-facing render contract.
### 2. Introduce ambiguity-first handling for overloaded business words
Critical words such as:
- "заработала"
- "прибыль"
- "деньги"
- "должны"
- "остатки"
- "оборот"
- "выручка"
must not silently collapse into one interpretation.
The answer should explicitly split the meaning when needed:
- cashflow meaning;
- accounting profit meaning;
- debt snapshot meaning;
- turnover meaning.
### 3. Force management-first answer order
The default order should become:
1. business conclusion;
2. 3-5 key figures;
3. one short boundary/limitation line;
4. next useful step.
This order should be preferred over:
- raw table-first answers;
- long limitation-first answers;
- technical explanation-first answers.
### 4. Suppress misleading aggregate quantities
For heterogeneous inventory and similar mixed lists, the runtime should avoid presenting meaningless aggregate counts as if they were strong management metrics.
This is especially important when a count mixes radically different unit types.
### 5. Add actionability by contract
Every relevant answer family should end with a next useful option, for example:
- show document drilldown;
- split by months;
- separate bank from clients;
- compute pure accounting profit;
- show overdue aging;
- open top counterparties;
- explain why this debt remains open.
This should be contract-driven, not an optional wording flourish.
## Required Audit Changes
### 1. Upgrade replay verdict language
The replay review should explicitly distinguish:
- factually wrong;
- factually right but business-wrong;
- factually right but ambiguity-handled-poorly;
- factually right but too technical/leaky;
- factually right but low-actionability.
### 2. Add stronger answer-quality probes
The semantic audit should explicitly score:
- management conclusion present or absent;
- key figure prioritization quality;
- limitation compression quality;
- ambiguity handling quality;
- next-step usefulness;
- bank/business contour classification quality;
- follow-up interpretation continuity.
### 3. Treat “technical cleanliness” as a first-class acceptance surface
If the answer leaks technical mechanics into the business response, that must be treated as a real quality defect, not as harmless debug residue.
### 4. Strengthen human-style post-run findings
The replay artifacts should move closer to the human audit style:
- what worked;
- what the user actually wanted;
- what was answered instead;
- why the answer is still weak;
- what exact runtime behavior should be changed.
## Proposed Execution Order
The next execution order should be:
1. freeze this module and Tasker it;
2. implement runtime answer-shape uplift first;
3. rerun focused semantic packs against the same business questions;
4. then tighten replay audit scoring and narrative findings using the new denominator;
5. only after that widen breadth again.
Reason:
- if audit is improved before answer contracts, we mostly get stronger failure reporting;
- if answer contracts are improved first, the next audit pass can judge a better target surface.
## Acceptance Criteria
The module should be accepted only when the following become true on focused replay packs.
### Runtime acceptance
- direct answers start with business meaning, not caveat noise;
- ambiguous business terms are split honestly when needed;
- cashflow vs profit confusion is handled explicitly;
- bank-like contours are classified without pretending they are ordinary customer revenue;
- mixed inventory counts are not surfaced as misleading management totals;
- next-step guidance is present and useful.
### Audit acceptance
- weak business answers are called out even when retrieval is correct;
- replay findings read closer to a senior analyst review than to a metric dump;
- `accountant_actionability_score`, `mechanism_specificity_score`, and `followup_context_retention_score` stop being chronically weak on the target pack;
- accepted runs no longer pass merely because the truth layer is clean.
## What This Module Is Not
This module is not:
- a new domain-route spree;
- a planner rewrite;
- a truth-gate rollback;
- a generic UX polish task.
It is the missing business-answer contract between the already-strong retrieval core and the user.
## Recommended Immediate Tasker Shape
One active card should represent this module explicitly, with subtasks for:
1. answer intent shapes;
2. ambiguity handler for overloaded business words;
3. business-first render order;
4. actionability contract;
5. semantic audit uplift and replay rubric alignment.
## Honest Status Line
The project is now at the point where:
- retrieval competence is no longer the main differentiator;
- answer usefulness and replay audit quality are the next denominator;
- if this module lands well, the assistant will stop sounding like a debug log and start sounding like a real 1C analyst.

View File

@ -58,13 +58,15 @@ This package answers the next question:
38. [38 - financial_role_purpose_arbitration_2026-05-13.md](./38%20-%20financial_role_purpose_arbitration_2026-05-13.md)
39. [39 - generic_role_tail_anchor_hygiene_2026-05-13.md](./39%20-%20generic_role_tail_anchor_hygiene_2026-05-13.md)
40. [40 - mixed_schema_primitive_closure_replay_2026-05-13.md](./40%20-%20mixed_schema_primitive_closure_replay_2026-05-13.md)
41. [41 - assistant_context_entry_2026-05-18.md](./41%20-%20assistant_context_entry_2026-05-18.md)
## Current Status Snapshot (2026-05-13)
## Current Status Snapshot (2026-05-18)
This package is no longer planning-only.
Status canon for planning:
- The fastest current handoff document is now [41 - assistant_context_entry_2026-05-18.md](./41%20-%20assistant_context_entry_2026-05-18.md).
- The current operational overlay is now [24 - agentic_semantic_development_loop_and_autorun_hygiene_2026-05-10.md](./24%20-%20agentic_semantic_development_loop_and_autorun_hygiene_2026-05-10.md).
- The active engineering surface is no longer only individual route hardening; it is the repo-native AGENT/stage-loop operating system that should generate/review/replay/audit/repair/rerun current-stage packs before saving accepted autoruns.
- The first dogfood stage loop for `agentic_semantic_development_loop` is accepted in artifacts, but manual GUI confirmation remains required before treating a fat AGENT pack as fully human-accepted.
@ -153,6 +155,7 @@ Status canon for planning:
- The seventh broader schema/primitive discovery support slice is [38 - financial_role_purpose_arbitration_2026-05-13.md](./38%20-%20financial_role_purpose_arbitration_2026-05-13.md), now accepted live and saved as a user-runnable AGENT autorun.
- The eighth broader schema/primitive discovery support slice is [39 - generic_role_tail_anchor_hygiene_2026-05-13.md](./39%20-%20generic_role_tail_anchor_hygiene_2026-05-13.md), now accepted live and saved as a user-runnable AGENT autorun.
- The mixed schema/primitive closure replay is [40 - mixed_schema_primitive_closure_replay_2026-05-13.md](./40%20-%20mixed_schema_primitive_closure_replay_2026-05-13.md), now accepted live and saved as a user-runnable AGENT autorun.
- The latest saved-session semantic integrity closure is `saved_session_gen_mo1t93wq_jy0453e_rerun_final_semantic_20260518`, accepted `31/31` after repairing VAT same-period carryover, stale MCP-discovery counterparty carryover in short debt mirror turns, sale-trace lot/batch honesty, and broad best-year net-ranking honesty.
It now documents a turnaround that is already operational in code, already materially past the acute regression breakpoint, and already moved through bounded MCP autonomy, Post-F hardening, inventory breadth proof, and the declared Planner Autonomy slice:
@ -423,6 +426,7 @@ Read in this order:
39. `38 - financial_role_purpose_arbitration_2026-05-13.md`
40. `39 - generic_role_tail_anchor_hygiene_2026-05-13.md`
41. `40 - mixed_schema_primitive_closure_replay_2026-05-13.md`
42. `41 - assistant_context_entry_2026-05-18.md`
## Planning Rules

View File

@ -50,6 +50,12 @@
"expected_requested_result_modes": ["confirmed_balance"],
"expected_result_modes": ["confirmed_balance"]
},
{
"intent": "inventory_margin_ranking_for_nomenclature",
"expected_selected_recipes": ["address_inventory_margin_ranking_for_nomenclature_v1"],
"expected_requested_result_modes": ["confirmed_balance"],
"expected_result_modes": ["confirmed_balance"]
},
{
"intent": "inventory_purchase_to_sale_chain",
"expected_selected_recipes": ["address_inventory_purchase_to_sale_chain_v1"],

View File

@ -0,0 +1,189 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "agent_autonomy_business_quality_20260523",
"domain": "autonomy_business_quality",
"title": "AGENT | Autonomy business quality pack",
"description": "Expanded targeted AGENT replay for the current autonomy milestone: value-flow hot handoff must survive realistic business questions, while final answers must read like a useful 1C analyst instead of runtime/debug prose.",
"bindings": {},
"steps": [
{
"step_id": "step_01_incoming_total_no_counterparties",
"title": "Incoming money total by organization without counterparty split",
"question": "Сколько входящих денег за 2020 год по ООО Альтернатива Плюс без разреза по контрагентам?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"expected_mcp_discovery_response_applied": true,
"expected_mcp_discovery_selected_chain_id": "value_flow",
"expected_mcp_discovery_response_candidate_status": "ready_for_guarded_use",
"expected_mcp_discovery_candidate_hot_runtime_wired": true,
"expected_mcp_discovery_hot_runtime_wired": true,
"expected_mcp_discovery_execution_handoff_status": "ready_for_guarded_response",
"expected_mcp_discovery_execution_handoff_can_use_guarded_response": true,
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "value_flow",
"expected_catalog_selected_matches_top": true,
"required_answer_patterns_all": ["2020", "47[\\s.]*628[\\s.]*853", "входящ|поступ|получ", "руб"],
"forbidden_answer_patterns": ["Учтено строк", "Первая найденная дата", "последняя:", "runtime_", "planner_", "query_movements", "primitive", "уточните контрагента", "по какому контрагенту"],
"criticality": "critical",
"semantic_tags": ["autonomy_core", "value_flow", "incoming_total", "business_answer_quality"]
},
{
"step_id": "step_02_outgoing_total_no_counterparties",
"title": "Outgoing money total by organization without counterparty split",
"question": "Сколько исходящих денег за 2020 год по ООО Альтернатива Плюс без разреза по контрагентам?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"expected_mcp_discovery_response_applied": true,
"expected_mcp_discovery_selected_chain_id": "value_flow",
"expected_mcp_discovery_response_candidate_status": "ready_for_guarded_use",
"expected_mcp_discovery_candidate_hot_runtime_wired": true,
"expected_mcp_discovery_hot_runtime_wired": true,
"expected_mcp_discovery_execution_handoff_status": "ready_for_guarded_response",
"expected_mcp_discovery_execution_handoff_can_use_guarded_response": true,
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "value_flow",
"expected_catalog_selected_matches_top": true,
"required_answer_patterns_all": ["2020", "исходящ|списан|заплат", "руб"],
"required_answer_patterns_any": ["43[\\s.]*763[\\s.]*351", "не полностью покрыт|лимит|нужно дозапрос"],
"forbidden_answer_patterns": ["Учтено строк", "Первая найденная дата", "последняя:", "runtime_", "planner_", "query_movements", "primitive", "уточните контрагента", "по какому контрагенту"],
"criticality": "critical",
"semantic_tags": ["autonomy_core", "value_flow", "outgoing_total", "business_answer_quality", "limit_honesty"]
},
{
"step_id": "step_03_colloquial_incoming_without_tops",
"title": "Colloquial no-top incoming wording stays value-flow total",
"question": "А всего сколько денег пришло в ООО Альтернатива Плюс за 2020, без топов и без контрагентов?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"expected_mcp_discovery_response_applied": true,
"expected_mcp_discovery_selected_chain_id": "value_flow",
"expected_mcp_discovery_response_candidate_status": "ready_for_guarded_use",
"expected_mcp_discovery_candidate_hot_runtime_wired": true,
"expected_mcp_discovery_hot_runtime_wired": true,
"expected_mcp_discovery_execution_handoff_status": "ready_for_guarded_response",
"expected_mcp_discovery_execution_handoff_can_use_guarded_response": true,
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "value_flow",
"expected_catalog_selected_matches_top": true,
"required_answer_patterns_all": ["2020", "47[\\s.]*628[\\s.]*853", "пришл|получ|поступ", "руб"],
"forbidden_answer_patterns": ["топ входящ", "топ контрагент", "Учтено строк", "Первая найденная дата", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["autonomy_core", "value_flow", "no_top_guard", "colloquial_total"]
},
{
"step_id": "step_04_cashflow_overview_2020",
"title": "Adult 2020 money overview with bank separated",
"question": "Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток.",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "business_overview",
"expected_catalog_selected_matches_top": true,
"required_answer_patterns_all": ["47[\\s.]*628[\\s.]*853", "43[\\s.]*763[\\s.]*351", "3[\\s.]*865[\\s.]*501", "Сбербанк|СБЕРБАНК", "финансов|банк", "Группа СВК", "Департамент"],
"required_answer_patterns_any": ["Что проверить дальше", "следующ"],
"forbidden_answer_patterns": ["Учтено строк", "Первая найденная дата", "surrogate", "VAT-объект", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["business_overview", "cashflow_overview", "bank_boundary", "next_action"]
},
{
"step_id": "step_05_money_earned_vs_profit",
"title": "Colloquial earned money is cashflow, not clean profit",
"question": "скока денег альтернатива заработала за 20 год?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["47[\\s.]*628[\\s.]*853", "43[\\s.]*763[\\s.]*351", "3[\\s.]*865[\\s.]*501", "не чистая прибыль|не прибыль|денежн"],
"forbidden_answer_patterns": ["Учтено строк", "Первая найденная дата", "surrogate", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["cashflow_vs_profit", "colloquial_money"]
},
{
"step_id": "step_06_profit_followup",
"title": "Follow-up clean profit keeps cashflow/profit distinction",
"question": "а это чистая прибыль?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["нет|не", "чистая прибыль|прибыль", "7\\s*136\\s*815|7,?136,?815|7136815", "90|91|99"],
"forbidden_answer_patterns": ["Учтено строк", "Первая найденная дата", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["followup_context", "profit_vs_cashflow"]
},
{
"step_id": "step_07_sberbank_financial_flow",
"title": "Sberbank role is financial flow, not ordinary customer/supplier",
"question": "А отдельно по СБЕРБАНКУ: он для нас клиент, поставщик или финансовый поток? Дай коротко по подтвержденным строкам.",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["Сбербанк|СБЕРБАНК", "финансов|банк", "не.*клиент|не.*поставщик|не обычн", "36[\\s.]*258[\\s.]*835|12[\\s.]*792[\\s.]*194"],
"forbidden_answer_patterns": ["0 / 0", "T00:00:00", "Учтено строк", "Первая найденная дата", "Примеры строк", "Показаны первые", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["bank_classification", "business_answer_quality"]
},
{
"step_id": "step_08_svk_counterparty_net_flow",
"title": "Counterparty net-flow does not become company profit",
"question": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["Группа СВК", "12[\\s.]*093[\\s.]*465", "заплатили|исходящ", "0", "нетто"],
"forbidden_answer_patterns": ["чистая прибыль", "90/91/99", "7[\\s.]*136[\\s.]*815", "Учтено строк", "Первая найденная дата", "Примеры строк", "Показаны первые", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["counterparty_value_flow", "net_flow", "no_profit_substitution"]
},
{
"step_id": "step_09_payables_end_2020",
"title": "Payables answer stays compact and business-first",
"question": "кому мы должны на конец 2020?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["1[\\s.]*713[\\s.]*210", "ИП Тучкова|Тучкова", "должн|кредитор"],
"forbidden_answer_patterns": ["T23:59:59", "эвристический", "shortlist", "Строк в выборке", "Учтено строк", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["payables", "debt_answer_quality"]
},
{
"step_id": "step_10_receivables_end_2020",
"title": "Receivables answer stays compact and business-first",
"question": "а нам кто должен на конец 2020?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["1[\\s.]*219[\\s.]*200", "СервисКонсалт", "должн|дебитор"],
"forbidden_answer_patterns": ["T23:59:59", "эвристический", "shortlist", "Строк в выборке", "Категории дебиторской задолженности", "Учтено строк", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["receivables", "debt_answer_quality"]
},
{
"step_id": "step_11_vat_december_2019",
"title": "VAT answer is clean user-facing accounting answer",
"question": "сколько НДС надо заплатить в налоговую за декабрь 2019?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["161[\\s.]*852", "к уплате", "книга продаж|продаж", "книга покупок|вычет|покупок", "01\\.10\\.2019|4 квартал|IV квартал"],
"forbidden_answer_patterns": ["surrogate", "VAT-объект", "прямых источников", "0,00\\s*₽", "Учтено строк", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["vat", "technical_garbage_guard"]
},
{
"step_id": "step_12_business_evaluation",
"title": "Company evaluation is an analyst answer with next checks",
"question": "Как ты оценишь деятельность компании?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "business_overview",
"expected_catalog_selected_matches_top": true,
"required_answer_patterns_all": ["крупн|контракт|проект", "285\\s*8|285\\s*823|285", "Комитет|госуслуг", "Что проверить дальше|проверить дальше|дальше"],
"forbidden_answer_patterns": ["Учтено строк", "Первая найденная дата", "проверка достигла лимита строк", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["business_overview", "business_evaluation", "next_action"]
},
{
"step_id": "step_13_nomenclature_margin_missing_period",
"title": "Nomenclature margin ranking asks for period, not wrong accounting domain",
"question": "Какая номенклатура товара реализована с высокой прибылью какая с низкой?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["период", "выручк|реализац", "себестоим|валов|маржин"],
"forbidden_answer_patterns": ["основн.*средств", "амортизац", "зависш", "оплат", "расчетн", "Учтено строк", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["nomenclature_margin", "clarification", "domain_purity"]
},
{
"step_id": "step_14_nomenclature_margin_may_2020",
"title": "Nomenclature margin ranking with period gives useful limited answer",
"question": "май 2020",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": ["май|01\\.05\\.2020|31\\.05\\.2020", "рейтинг|прибыль|маржин", "себестоим", "нельзя|не удалось|недостаточно"],
"required_answer_patterns_any": ["показать найденную продажу", "расширить период", "90\\.01|90\\.02", "следующ"],
"forbidden_answer_patterns": ["основн.*средств", "амортизац", "зависш", "runtime_", "planner_", "query_movements", "primitive"],
"criticality": "critical",
"semantic_tags": ["nomenclature_margin", "limited_answer", "next_action"]
}
]
}

View File

@ -0,0 +1,141 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "agent_cashflow_compact_display_variants_20260523",
"domain": "autonomy_business_answer_contract",
"title": "AGENT | Cashflow compact display variants",
"description": "Targeted AGENT replay for compact cashflow display modifiers: no counterparties, no breakdown, one-line and only-total follow-ups must preserve the 2020 company cashflow context.",
"bindings": {},
"steps": [
{
"step_id": "step_01_explicit_overview_anchor",
"title": "Create 2020 business overview context",
"question": "Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток.",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501",
"12[\\s.]*792[\\s.]*194",
"12[\\s.]*093[\\s.]*465"
],
"forbidden_answer_patterns": ["runtime_", "planner_", "query_movements", "primitive"],
"criticality": "high",
"semantic_tags": ["business_overview", "context_anchor"]
},
{
"step_id": "step_02_no_counterparties",
"title": "No-counterparties means no breakdown, not exclusion",
"question": "сколько заработали деньгами без контрагентов?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"2020",
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"2026-05-23",
"получили\\s+0\\s*руб",
"нетто\\s+0\\s*руб",
"с исключением",
"исключением крупнейших",
"Комитет государственных услуг",
"Группа СВК",
"СБЕРБАНК",
"Что проверить дальше",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": ["no_counterparty_breakdown", "display_modifier", "temporal_carryover"]
},
{
"step_id": "step_03_only_total",
"title": "Only total keeps compact cashflow",
"question": "только итог: пришло, ушло, нетто",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"2020",
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"2026-05-23",
"получили\\s+0\\s*руб",
"нетто\\s+0\\s*руб",
"Комитет государственных услуг",
"Группа СВК",
"СБЕРБАНК",
"Что проверить дальше",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": ["only_total", "display_modifier", "temporal_carryover"]
},
{
"step_id": "step_04_one_line_no_breakdown",
"title": "One-line no-breakdown stays cashflow",
"question": "дай одной строкой деньги без разбивки",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"2020",
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"2026-05-23",
"получили\\s+0\\s*руб",
"нетто\\s+0\\s*руб",
"Комитет государственных услуг",
"Группа СВК",
"СБЕРБАНК",
"Что проверить дальше",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": ["one_line", "no_breakdown", "display_modifier"]
},
{
"step_id": "step_05_no_details",
"title": "No-details wording remains direct totals",
"question": "сколько получили и заплатили без детализации",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"2020",
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"2026-05-23",
"получили\\s+0\\s*руб",
"нетто\\s+0\\s*руб",
"Комитет государственных услуг",
"Группа СВК",
"СБЕРБАНК",
"Что проверить дальше",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": ["no_details", "display_modifier", "direct_totals"]
}
],
"acceptance": {
"min_score": 80,
"max_unresolved_p0": 0,
"require_all_critical_steps_pass": true
}
}

View File

@ -0,0 +1,118 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "agent_cashflow_no_tops_20260523",
"domain": "autonomy_business_answer_contract",
"title": "AGENT | Cashflow no-tops display modifier",
"description": "Targeted AGENT replay: after a 2020 business overview, a compact 'без топов' follow-up must preserve the 2020 company cashflow context and suppress rankings instead of excluding top counterparties.",
"bindings": {},
"steps": [
{
"step_id": "step_01_direct_money_2020",
"title": "Direct cashflow baseline for 2020",
"question": "Сколько денег Альтернатива заработала за 2020 год? Ответь коротко: получили, заплатили, денежное нетто, это прибыль или нет.",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive",
"7\\s*136\\s*815|7136815"
],
"criticality": "critical",
"semantic_tags": ["cashflow", "direct_answer", "baseline"]
},
{
"step_id": "step_02_explicit_overview_with_tops",
"title": "Explicit overview may include top counterparties",
"question": "Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток.",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501",
"12[\\s.]*792[\\s.]*194",
"12[\\s.]*093[\\s.]*465",
"9[\\s.]*612[\\s.]*904"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "high",
"semantic_tags": ["business_overview", "tops_allowed", "bank_boundary"]
},
{
"step_id": "step_03_compact_no_tops_followup",
"title": "No-tops compact follow-up preserves 2020 cashflow",
"question": "а если коротко, сколько заработали деньгами без топов?",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"2020",
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"2026-05-23",
"получили\\s+0\\s*руб",
"заплатили(?:/списали)?\\s+0\\s*руб",
"нетто\\s+0\\s*руб",
"с исключением крупнейших",
"исключением крупнейших",
"Комитет государственных услуг",
"Группа СВК",
"СБЕРБАНК",
"Что проверить дальше",
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": ["compact_after_overview", "no_tops_display_modifier", "temporal_carryover"]
},
{
"step_id": "step_04_plain_money_in_out_net",
"title": "Explicit plain money request remains compact",
"question": "не обзор, просто деньги: пришло, ушло, нетто за 2020",
"allowed_reply_types": ["partial_coverage", "factual_with_explanation", "factual"],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"Комитет государственных услуг",
"Группа СВК",
"СБЕРБАНК",
"Что проверить дальше",
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": ["direct_money_only", "ranking_suppression"]
}
],
"acceptance": {
"min_score": 80,
"max_unresolved_p0": 0,
"require_all_critical_steps_pass": true
}
}

View File

@ -0,0 +1,266 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "agent_cashflow_profit_directness_20260523",
"domain": "autonomy_business_answer_contract",
"title": "AGENT | Cashflow vs profit directness pack",
"description": "Targeted AGENT replay for cashflow-vs-profit directness: colloquial earnings questions must produce a compact money answer, while broad overview remains available only when explicitly requested.",
"bindings": {
},
"steps": [
{
"step_id": "step_01_direct_money_earned_explicit_shape",
"title": "Direct money earned question asks for compact cashflow shape",
"question": "Сколько денег Альтернатива заработала за 2020 год? Ответь коротко: получили, заплатили, денежное нетто, это прибыль или нет.",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive",
"7\\s*136\\s*815|7136815",
"Комитет государственных услуг"
],
"criticality": "critical",
"semantic_tags": [
"cashflow_vs_profit",
"direct_answer",
"colloquial_earnings"
]
},
{
"step_id": "step_02_colloquial_money_earned",
"title": "Colloquial earned wording stays cashflow, not broad overview",
"question": "скока денег альтернатива заработала за 20 год?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive",
"7\\s*136\\s*815|7136815",
"Комитет государственных услуг",
"Что проверить дальше"
],
"criticality": "critical",
"semantic_tags": [
"cashflow_vs_profit",
"slang_wording",
"no_overview_substitution"
]
},
{
"step_id": "step_03_profit_followup_after_cashflow",
"title": "Clean profit follow-up keeps distinction",
"question": "а это чистая прибыль?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"7\\s*136\\s*815|7136815",
"90|91|99"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": [
"profit_vs_cashflow",
"followup_context"
]
},
{
"step_id": "step_04_money_plus_or_minus_followup",
"title": "Money plus/minus follow-up returns cashflow net",
"question": "а по деньгам тогда плюс или минус?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"\\+",
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive",
"7\\s*136\\s*815|7136815"
],
"criticality": "critical",
"semantic_tags": [
"cashflow_polarity",
"followup_context",
"no_profit_substitution"
]
},
{
"step_id": "step_05_explicit_business_overview_allowed",
"title": "Explicit adult overview may include business structure and next steps",
"question": "Теперь дай взрослый обзор за 2020 по компании: входящие, исходящие, нетто, топы, но банк в топах отдельно объясни как финансовый поток.",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501",
"12[\\s.]*792[\\s.]*194",
"12[\\s.]*093[\\s.]*465",
"9[\\s.]*612[\\s.]*904"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "high",
"semantic_tags": [
"business_overview",
"bank_flow_boundary",
"next_steps"
]
},
{
"step_id": "step_06_return_to_short_money_after_overview",
"title": "Short money follow-up after overview keeps 2020 context",
"question": "а если коротко, сколько заработали деньгами без топов?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"2020",
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive",
"285[\\s.]*819[\\s.]*547",
"147[\\s.]*855[\\s.]*827",
"Комитет государственных услуг",
"Что проверить дальше"
],
"criticality": "critical",
"semantic_tags": [
"compact_after_overview",
"temporal_carryover",
"no_all_time_leak"
]
},
{
"step_id": "step_07_plain_money_in_out_net",
"title": "Plain money request suppresses broad overview",
"question": "не обзор, просто деньги: пришло, ушло, нетто за 2020",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"47[\\s.]*628[\\s.]*853",
"43[\\s.]*763[\\s.]*351",
"3[\\s.]*865[\\s.]*501"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive",
"Комитет государственных услуг",
"12[\\s.]*093[\\s.]*465",
"12[\\s.]*792[\\s.]*194",
"Что проверить дальше"
],
"criticality": "critical",
"semantic_tags": [
"direct_money_only",
"ranking_suppression"
]
},
{
"step_id": "step_08_direct_clean_profit_2020",
"title": "Explicit clean profit goes to accounting result, not cashflow",
"question": "какая чистая прибыль по Альтернативе за 2020?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"7\\s*136\\s*815|7136815",
"90|91|99"
],
"forbidden_answer_patterns": [
"Учтено строк",
"Первая найденная дата",
"runtime_",
"planner_",
"query_movements",
"primitive",
"получили\\s+47[\\s.]*628[\\s.]*853",
"денежное\\s+нетто\\s+3[\\s.]*865[\\s.]*501"
],
"criticality": "critical",
"semantic_tags": [
"clean_profit",
"profit_route",
"no_cashflow_substitution"
]
}
],
"acceptance": {
"min_score": 80,
"max_unresolved_p0": 0,
"require_all_critical_steps_pass": true
}
}

View File

@ -0,0 +1,157 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "agent_hot_value_flow_handoff_20260522",
"domain": "autonomy_hot_value_flow_handoff",
"title": "AGENT | Hot value-flow discovery handoff",
"description": "Targeted AGENT replay for the current Autonomy Core slice: organization-scoped value-flow questions must be answered through guarded MCP discovery response with hot handoff, not through stale exact fallback or a counterparty-only route.",
"bindings": {},
"steps": [
{
"step_id": "step_01_incoming_total_hot_handoff",
"title": "Organization-scoped incoming total uses hot value-flow discovery candidate",
"question": "Сколько входящих денег за 2020 год по ООО Альтернатива Плюс без разреза по контрагентам?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"expected_mcp_discovery_response_applied": true,
"expected_mcp_discovery_selected_chain_id": "value_flow",
"expected_mcp_discovery_response_candidate_status": "ready_for_guarded_use",
"expected_mcp_discovery_candidate_hot_runtime_wired": true,
"expected_mcp_discovery_hot_runtime_wired": true,
"expected_mcp_discovery_execution_handoff_status": "ready_for_guarded_response",
"expected_mcp_discovery_execution_handoff_can_use_guarded_response": true,
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "value_flow",
"expected_catalog_selected_matches_top": true,
"expected_route_candidate_status": "ready_for_reviewed_execution",
"expected_route_candidate_executable_now": true,
"required_answer_patterns_all": [
"(?i)2020",
"(?i)входящ|получ|поступ",
"(?i)руб"
],
"required_answer_patterns_any": [
"(?i)Альтернатива",
"(?i)проверенн",
"(?i)1С"
],
"forbidden_answer_patterns": [
"(?i)уточните контрагента",
"(?i)по какому контрагенту",
"(?i)не найден контрагент",
"(?i)runtime_",
"(?i)planner_",
"(?i)query_movements",
"(?i)primitive"
],
"criticality": "critical",
"semantic_tags": [
"autonomy_core",
"value_flow",
"hot_handoff",
"guarded_response",
"incoming_total"
]
},
{
"step_id": "step_02_outgoing_total_hot_handoff",
"title": "Organization-scoped outgoing total uses hot value-flow discovery candidate",
"question": "Сколько исходящих денег за 2020 год по ООО Альтернатива Плюс без разреза по контрагентам?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"expected_mcp_discovery_response_applied": true,
"expected_mcp_discovery_selected_chain_id": "value_flow",
"expected_mcp_discovery_response_candidate_status": "ready_for_guarded_use",
"expected_mcp_discovery_candidate_hot_runtime_wired": true,
"expected_mcp_discovery_hot_runtime_wired": true,
"expected_mcp_discovery_execution_handoff_status": "ready_for_guarded_response",
"expected_mcp_discovery_execution_handoff_can_use_guarded_response": true,
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "value_flow",
"expected_catalog_selected_matches_top": true,
"expected_route_candidate_status": "ready_for_reviewed_execution",
"expected_route_candidate_executable_now": true,
"required_answer_patterns_all": [
"(?i)2020",
"(?i)исходящ|списан|заплат",
"(?i)руб"
],
"required_answer_patterns_any": [
"(?i)Альтернатива",
"(?i)проверенн",
"(?i)1С"
],
"forbidden_answer_patterns": [
"(?i)уточните контрагента",
"(?i)по какому контрагенту",
"(?i)не найден контрагент",
"(?i)runtime_",
"(?i)planner_",
"(?i)query_movements",
"(?i)primitive"
],
"criticality": "critical",
"semantic_tags": [
"autonomy_core",
"value_flow",
"hot_handoff",
"guarded_response",
"outgoing_total"
]
},
{
"step_id": "step_03_colloquial_money_total_hot_handoff",
"title": "Colloquial company money wording still uses hot value-flow discovery candidate",
"question": "А всего сколько денег пришло в ООО Альтернатива Плюс за 2020, без топов и без контрагентов?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"expected_mcp_discovery_response_applied": true,
"expected_mcp_discovery_selected_chain_id": "value_flow",
"expected_mcp_discovery_response_candidate_status": "ready_for_guarded_use",
"expected_mcp_discovery_candidate_hot_runtime_wired": true,
"expected_mcp_discovery_hot_runtime_wired": true,
"expected_mcp_discovery_execution_handoff_status": "ready_for_guarded_response",
"expected_mcp_discovery_execution_handoff_can_use_guarded_response": true,
"expected_catalog_alignment_status": "selected_matches_top",
"expected_catalog_chain_top_match": "value_flow",
"expected_catalog_selected_matches_top": true,
"expected_route_candidate_status": "ready_for_reviewed_execution",
"expected_route_candidate_executable_now": true,
"required_answer_patterns_all": [
"(?i)2020",
"(?i)пришл|получ|поступ|входящ",
"(?i)руб"
],
"required_answer_patterns_any": [
"(?i)Альтернатива",
"(?i)проверенн",
"(?i)1С"
],
"forbidden_answer_patterns": [
"(?i)уточните контрагента",
"(?i)по какому контрагенту",
"(?i)не найден контрагент",
"(?i)runtime_",
"(?i)planner_",
"(?i)query_movements",
"(?i)primitive"
],
"criticality": "critical",
"semantic_tags": [
"autonomy_core",
"value_flow",
"hot_handoff",
"guarded_response",
"colloquial_total"
]
}
]
}

View File

@ -0,0 +1,198 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "agent_inventory_margin_ranking_20260523",
"domain": "inventory_margin_ranking",
"title": "AGENT | Inventory margin ranking limited answer pack",
"description": "Targeted live replay for nomenclature high/low profit questions: ask period when missing, stay in inventory/sales/cost domain, give useful limited answer when cost evidence is insufficient, and avoid fixed-assets/bank leakage.",
"bindings": {
},
"steps": [
{
"step_id": "step_01_margin_root_needs_period",
"title": "Root asks nomenclature high/low profit without period",
"question": "Какая номеклатура товара реализована с высокой прибылью какая с низкой",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"период|месяц|квартал|год",
"номенклатур",
"выручк|себестоим|валов|маржин"
],
"forbidden_answer_patterns": [
"амортизац",
"основн[а-я]+ средств",
"\\bОС\\s+как\\s+объект\\b",
"Сбербанк",
"банк",
"зависш[а-я]+ оплат",
"payment",
"settlement cluster",
"runtime_",
"planner_",
"query_movements",
"primitive",
"90/91/99",
"7\\s*136\\s*815"
],
"criticality": "critical",
"semantic_tags": [
"inventory_margin_ranking",
"needs_period",
"domain_purity"
]
},
{
"step_id": "step_02_may_2020_period_limited_answer",
"title": "User provides May 2020 period and gets useful limited answer",
"question": "май 2020",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"май|01\\.05\\.2020|31\\.05\\.2020|2020",
"рейтинг|прибыль|маржин",
"себестоим|90\\.02|закупоч",
"нельзя|не удалось|недостаточ|не подтвержд"
],
"forbidden_answer_patterns": [
"амортизац",
"основн[а-я]+ средств",
"\\bОС\\s+как\\s+объект\\b",
"Сбербанк",
"банк",
"зависш[а-я]+ оплат",
"payment",
"settlement cluster",
"runtime_",
"planner_",
"query_movements",
"primitive",
"7\\s*136\\s*815"
],
"criticality": "critical",
"semantic_tags": [
"inventory_margin_ranking",
"limited_answer",
"period_followup"
]
},
{
"step_id": "step_03_show_cost_base_lines",
"title": "Follow-up asks for found cost-base evidence",
"question": "покажи найденные строки себестоимостной базы",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_any": [
"себестоим",
"закупоч",
"90\\.02",
"41",
"не найден|не подтвержд|нет"
],
"forbidden_answer_patterns": [
"амортизац",
"основн[а-я]+ средств",
"\\bОС\\s+как\\s+объект\\b",
"Сбербанк",
"банк",
"зависш[а-я]+ оплат",
"payment",
"settlement cluster",
"runtime_",
"planner_",
"query_movements",
"primitive",
"7\\s*136\\s*815"
],
"criticality": "high",
"semantic_tags": [
"inventory_margin_ranking",
"evidence_followup",
"carryover"
]
},
{
"step_id": "step_04_expand_to_2017",
"title": "User expands period to full 2017",
"question": "расширь до 2017 года",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_any": [
"2017",
"рейтинг|маржин|прибыль",
"себестоим|90\\.02|закупоч",
"нельзя|не подтвержд|найден"
],
"forbidden_answer_patterns": [
"амортизац",
"основн[а-я]+ средств",
"\\bОС\\s+как\\s+объект\\b",
"Сбербанк",
"банк",
"зависш[а-я]+ оплат",
"payment",
"settlement cluster",
"runtime_",
"planner_",
"query_movements",
"primitive",
"7\\s*136\\s*815"
],
"criticality": "high",
"semantic_tags": [
"inventory_margin_ranking",
"period_expansion",
"carryover"
]
},
{
"step_id": "step_05_account_41_not_01",
"title": "User corrects account family to 41 not fixed assets",
"question": "анализ по 41 счету а не 01",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_any": [
"41",
"товар|номенклатур|закупоч|себестоим",
"не 01|01"
],
"forbidden_answer_patterns": [
"амортизац",
"основн[а-я]+ средств",
"ОС как объект",
"Сбербанк",
"банк",
"runtime_",
"planner_",
"query_movements",
"primitive"
],
"criticality": "critical",
"semantic_tags": [
"inventory_margin_ranking",
"account_family_guard",
"no_fixed_assets"
]
}
],
"acceptance": {
"min_score": 80,
"max_unresolved_p0": 0,
"require_all_critical_steps_pass": true
}
}

View File

@ -0,0 +1,96 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "agent_profit_direct_answer_20260523",
"domain": "cashflow_profit_boundary",
"title": "AGENT | Direct net profit answer shape",
"description": "Targeted replay for direct clean-profit questions: follow-up boundary may say cashflow is not profit, but a standalone net-profit question must answer with the accounting result first.",
"bindings": {},
"steps": [
{
"step_id": "step_01_cashflow_money_not_profit",
"title": "Cashflow question asks money and profit boundary",
"question": "Сколько денег Альтернатива заработала за 2020 год? Ответь коротко: получили, заплатили, денежное нетто, это прибыль или нет.",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"47\\s*628\\s*853,03",
"43\\s*763\\s*351,53",
"3\\s*865\\s*501,50",
"не\\s+чистая\\s+прибыль|не\\s+бухгалтерский\\s+финрезультат"
],
"forbidden_answer_patterns": [
"7\\s*136\\s*815,85.*получили",
"runtime_",
"planner_",
"primitive"
],
"criticality": "critical",
"semantic_tags": [
"cashflow",
"profit_boundary"
]
},
{
"step_id": "step_02_followup_this_is_profit",
"title": "Follow-up asks whether cashflow is clean profit",
"question": "а это чистая прибыль?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"нет",
"90/91/99",
"7\\s*136\\s*815,85",
"убыт"
],
"forbidden_answer_patterns": [
"получили\\s+47\\s*628\\s*853,03.*чистая\\s+прибыль",
"runtime_",
"planner_",
"primitive"
],
"criticality": "critical",
"semantic_tags": [
"profit_boundary",
"followup"
]
},
{
"step_id": "step_03_direct_net_profit_question",
"title": "Standalone direct net profit question answers with result first",
"question": "какая чистая прибыль по Альтернативе за 2020?",
"allowed_reply_types": [
"partial_coverage",
"factual_with_explanation",
"factual"
],
"required_answer_patterns_all": [
"90/91/99",
"7\\s*136\\s*815,85",
"убыт",
"не\\s+денежный\\s+поток|учетный\\s+финрезультат"
],
"forbidden_answer_patterns": [
"Коротко:\\s*нет,\\s*денежное\\s+операционное\\s+нетто",
"runtime_",
"planner_",
"primitive"
],
"criticality": "critical",
"semantic_tags": [
"direct_profit",
"answer_shape"
]
}
],
"acceptance": {
"min_score": 80,
"max_unresolved_p0": 0,
"require_all_critical_steps_pass": true
}
}

View File

@ -0,0 +1,109 @@
{
"schema_version": "domain_scenario_manifest_v1",
"scenario_id": "inventory_margin_ranking_agent_loop_20260522",
"domain": "inventory_margin_ranking",
"title": "Inventory nomenclature margin ranking limited-answer loop",
"description": "Shared-session replay for nomenclature profitability ranking: missing period clarification, period-scoped insufficient realization/cost evidence, and follow-up action quality.",
"acceptance_canon": {
"root_step_id": "step_01_margin_root_needs_period",
"primary_user_path": [
"step_01_margin_root_needs_period",
"step_02_september_2017_period",
"step_03_show_cost_base_lines",
"step_04_expand_to_2017",
"step_05_account_41_not_01"
],
"required_paraphrase_families": [
"colloquial",
"canonical"
],
"required_carryover_invariants": [
"intent_scope",
"period_scope",
"organization_scope",
"answer_shape"
]
},
"analysis_context": {
"as_of_date": "2026-05-22",
"source": "manual_export_and_codex_agent_loop"
},
"steps": [
{
"step_id": "step_01_margin_root_needs_period",
"title": "Root asks nomenclature high/low profit without period",
"question": "Какая номеклатура товара реализована с высокой прибылью какая с низкой",
"node_role": "root",
"paraphrase_family": "colloquial",
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_result_mode": "clarification_required"
},
{
"step_id": "step_02_september_2017_period",
"title": "User provides period",
"question": "сентябрь 2017",
"node_role": "critical_child",
"paraphrase_family": "colloquial",
"depends_on": [
"step_01_margin_root_needs_period"
],
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_result_mode": "limited_accounting_answer",
"required_carryover_invariants": [
"intent_scope",
"period_scope",
"organization_scope"
]
},
{
"step_id": "step_03_show_cost_base_lines",
"title": "Follow-up asks for found evidence after no sales",
"question": "покажи найденные строки себестоимостной базы",
"node_role": "critical_child",
"paraphrase_family": "canonical",
"depends_on": [
"step_02_september_2017_period"
],
"expected_result_mode": "evidence_or_honest_boundary",
"required_carryover_invariants": [
"intent_scope",
"period_scope",
"organization_scope"
]
},
{
"step_id": "step_04_expand_to_2017",
"title": "User expands period to full year",
"question": "расширь до 2017 года",
"node_role": "critical_child",
"paraphrase_family": "colloquial",
"depends_on": [
"step_02_september_2017_period"
],
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_result_mode": "ranking_or_limited_accounting_answer",
"required_carryover_invariants": [
"intent_scope",
"period_scope",
"organization_scope"
]
},
{
"step_id": "step_05_account_41_not_01",
"title": "User corrects account family to 41 not fixed assets",
"question": "анализ по 41 счету а не 01",
"node_role": "critical_child",
"paraphrase_family": "colloquial",
"depends_on": [
"step_04_expand_to_2017"
],
"expected_capability": "inventory_inventory_margin_ranking_for_nomenclature",
"expected_result_mode": "same_inventory_margin_context_or_clarification",
"required_carryover_invariants": [
"intent_scope",
"period_scope",
"organization_scope"
]
}
]
}

View File

@ -1008,7 +1008,8 @@ function loadSessionDialog(runId, caseId) {
text: toStringSafe(item.text) ?? "",
created_at: toStringSafe(item.created_at),
trace_id: toStringSafe(item.trace_id),
reply_type: toStringSafe(item.reply_type)
reply_type: toStringSafe(item.reply_type),
debug: item.debug ?? null
}));
const turns = toArray(record.turns)
.map((item) => toRecord(item))
@ -1076,7 +1077,8 @@ function buildFallbackDialog(run, caseId) {
text: userText,
created_at: null,
trace_id: null,
reply_type: null
reply_type: null,
debug: null
},
{
message_id: null,
@ -1084,7 +1086,8 @@ function buildFallbackDialog(run, caseId) {
text: assistantSummaryParts.join("\n"),
created_at: null,
trace_id: toStringSafe(targetCase.trace_id),
reply_type: toStringSafe(targetCase.reply_type)
reply_type: toStringSafe(targetCase.reply_type),
debug: null
}
],
decomposition: [],

View File

@ -12,6 +12,7 @@ const COMPUTE_EXACT_INTENTS = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@ -68,6 +69,7 @@ function defaultCapabilityId(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") {
@ -151,12 +153,15 @@ function resolveCapabilityEnabled(intent) {
if (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain") {
if (intent === "inventory_profitability_for_item") {
if (intent === "inventory_profitability_for_item" || intent === "inventory_margin_ranking_for_nomenclature") {
return {
enabled: true,
reason: "inventory_profitability_route_enabled"
reason: intent === "inventory_margin_ranking_for_nomenclature"
? "inventory_margin_ranking_route_enabled"
: "inventory_profitability_route_enabled"
};
}
if (intent === "inventory_purchase_to_sale_chain") {
@ -249,6 +254,7 @@ function resolveShadowRouteIntent(intent, requestedResultMode) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") {

View File

@ -101,6 +101,7 @@ function isConfirmedBalanceIntent(intent) {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "open_contracts_confirmed_as_of_date" ||

View File

@ -971,6 +971,7 @@ function isInventoryTraceIntent(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -989,6 +990,7 @@ function usesRecipeDefaultLimit(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -1407,6 +1409,9 @@ function requiredFiltersByIntent(intent) {
intent === "inventory_purchase_to_sale_chain") {
return ["item"];
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
return ["period_from", "period_to"];
}
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}

View File

@ -1664,6 +1664,17 @@ function hasBidirectionalValueFlowComparisonSignal(text) {
const hasNetAmountCue = /(?:сколько|сумм|итог|нетто|сальдо|минус|net|total|sum)/iu.test(normalized);
return hasIncomingCue && hasOutgoingCue && hasComparisonCue && (hasValueFlowCue || hasNetAmountCue);
}
function hasNomenclatureMarginRankingSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
const hasNomenclatureCue = /(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasRealizationCue = /(?:реализован|реализац|продан|продаж|отгруж|41(?:[.,]0?1)?|90(?:[.,]\d{1,2})?|sales?|sold)/iu.test(normalized);
const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized);
const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые)|больш|меньш|ранж|рейтинг|high|low|top|rank|best|worst)/iu.test(normalized);
return hasNomenclatureCue && hasRealizationCue && hasMarginCue && hasRankingCue;
}
function hasVatPeriodInspectionBridgeSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase();
if (!/(?:ндс|vat)/iu.test(normalized)) {
@ -1736,6 +1747,9 @@ function resolveUnicodeAddressIntentBridge(text) {
if (hasSelectedObjectProfitabilityCue) {
return unicodeBridgeResolution("inventory_profitability_for_item", "high", "unicode_selected_object_profitability_bridge_signal_detected");
}
if (hasNomenclatureMarginRankingSignal(normalized)) {
return unicodeBridgeResolution("inventory_margin_ranking_for_nomenclature", "high", "unicode_nomenclature_margin_ranking_bridge_signal_detected");
}
const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(normalized);
if (hasOpenItemsAccountCue) {

View File

@ -1647,6 +1647,7 @@ function isOrganizationScopedInventoryIntent(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -1680,6 +1681,7 @@ function shouldDeferInventoryOrganizationClarification(intent, filters, semantic
return (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -1981,6 +1983,7 @@ function canAutoBroadenPeriodWindow(intent, filters) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -1990,6 +1993,7 @@ function shouldBoostAutoBroadenedLimit(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -2520,6 +2524,9 @@ async function tryComposeLlmLimitedReply(input) {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
return null;
}
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return null;
}
if (!shouldUseLlmLimitedReply(input.category)) {
return null;
}
@ -2580,6 +2587,13 @@ function composeLimitedReply(input) {
.map((item) => normalizeMissingAnchorLabel(String(item ?? "").trim()))
.filter((item) => item.length > 0)));
const missingAnchorPhrase = missingAnchorLabels.length > 0 ? missingAnchorLabels.join(", ") : "контрагент, договор, счет или период";
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
const heading = input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [
"По текущим условиям в доступном срезе данных совпадений не нашлось.",

View File

@ -966,6 +966,17 @@ const BASE_RECIPES = [
account_scope_mode: "strict",
query_template: "inventory_trading_margin_proxy_profile"
},
{
recipe_id: "address_inventory_margin_ranking_for_nomenclature_v1",
intent: "inventory_margin_ranking_for_nomenclature",
purpose: "Rank realized nomenclature by bounded gross margin proxy for an explicit period using 41.01 purchase and sale document rows",
required_filters: ["period_from", "period_to"],
optional_filters: ["organization", "warehouse", "limit", "sort"],
default_limit: 800,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_margin_ranking_profile"
},
{
recipe_id: "address_inventory_purchase_to_sale_chain_v1",
intent: "inventory_purchase_to_sale_chain",
@ -1714,6 +1725,7 @@ function maxLimitForIntent(intent) {
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_trading_margin_proxy_for_organization" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@ -1905,33 +1917,17 @@ function buildAddressRecipePlan(recipe, filters) {
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_trading_margin_proxy_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
: recipe.query_template === "inventory_margin_ranking_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_quality_events_profile"
? buildInventoryQualityEventsQuery(filters, resolvedLimit)
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
@ -1946,10 +1942,10 @@ function buildAddressRecipePlan(recipe, filters) {
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
: recipe.query_template === "payables_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
@ -1964,20 +1960,38 @@ function buildAddressRecipePlan(recipe, filters) {
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
return {
recipe,
query,

View File

@ -129,15 +129,6 @@ function formatNumberWithDots(value, fractionDigits = 0) {
function formatMoneyRub(value) {
return `${formatNumberWithDots(value, 2)}`;
}
function formatVatProbeStatusRu(status) {
if (status === "ok") {
return "есть движения";
}
if (status === "empty") {
return "движения не найдены";
}
return "ошибка запроса";
}
function emphasizeNumericTokens(line) {
if (!line) {
return line;
@ -365,6 +356,63 @@ function bankOperationEvidenceLine(rows, preferredDirection = null) {
}
return `Основание 1С: ${parts.join("; ")}.`;
}
function classifyBankOperationSemanticBucket(row) {
const text = [
row.registrator,
row.operation_kind,
row.payment_purpose,
row.contract,
row.comment
]
.map((item) => String(item ?? "").toLowerCase())
.join(" ");
if (/(?:комисс|тариф|эквайр|обслуживан)/iu.test(text)) {
return "commission";
}
if (/(?:депозит|кредит|займ|овердрафт|процент|ссуд)/iu.test(text)) {
return "deposit_or_credit";
}
if (/(?:налог|ндс|взнос|бюджет|фнс|пфр|страхов)/iu.test(text)) {
return "tax_or_budget";
}
if (/(?:возврат|перевод|перечислен|переброс|пополн|инкасс|перенос)/iu.test(text)) {
return "transfer_or_return";
}
return "other";
}
function bankOperationSemanticBucketLabel(bucket) {
if (bucket === "commission") {
return "комиссии и банковское обслуживание";
}
if (bucket === "deposit_or_credit") {
return "депозиты, кредиты или проценты";
}
if (bucket === "tax_or_budget") {
return "налоги и бюджетные платежи";
}
if (bucket === "transfer_or_return") {
return "переводы, возвраты или перебросы";
}
return "прочие банковские операции";
}
function summarizeBankOperationSemantics(rows) {
if (rows.length === 0) {
return null;
}
const counts = new Map();
for (const row of rows) {
const bucket = classifyBankOperationSemanticBucket(row);
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
}
const ranked = Array.from(counts.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 3);
if (ranked.length === 0) {
return null;
}
const parts = ranked.map(([bucket, count]) => `${bankOperationSemanticBucketLabel(bucket)}${count}`);
return `По смыслу это скорее финансовый/банковский контур: ${parts.join("; ")}.`;
}
function bankRoleBoundaryLine(userMessage, rows) {
const incomingBoundary = hasBankIncomingRoleBoundaryQuestion(userMessage);
const outgoingBoundary = hasBankOutgoingRoleBoundaryQuestion(userMessage);
@ -2045,35 +2093,31 @@ function formatPayablesEvidenceSuffix(item) {
const suffix = item.documents.length > 1 ? ` (+${item.documents.length - 1})` : "";
parts.push(`документ: ${item.documents[0]}${suffix}`);
}
if (item.sourceRefs.length > 0) {
parts.push(`source refs: ${item.sourceRefs.slice(0, 2).join("; ")}`);
}
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function formatDebtMirrorGroupLine(item) {
const details = [
item.account ? `счет ${item.account}` : null,
item.contract ? `договор/аналитика: ${item.contract}` : null,
item.organization ? `организация: ${item.organization}` : null
].filter((part) => Boolean(part));
const netText = Math.abs(item.netAmount) <= 0.005
? "чисто: 0 ₽"
: item.netAmount > 0
? `чисто к получению: ${formatMoneyRub(item.netAmount)}`
: `чисто к оплате: ${formatMoneyRub(Math.abs(item.netAmount))}`;
return `${item.name}${details.length > 0 ? ` (${details.join(", ")})` : ""}: дебет ${formatMoneyRub(item.debitAmount)} / кредит ${formatMoneyRub(item.creditAmount)}, ${netText}.`;
return `${item.name}: встречная часть ${formatMoneyRub(Math.min(item.debitAmount, item.creditAmount))}, ${netText}.`;
}
function debtMirrorCleanScopeLabel(kind) {
return kind === "payables" ? "чистый долг к оплате" : "чистую дебиторку к получению";
function debtMirrorCleanScopeLabel(kind, grammaticalCase) {
if (kind === "payables") {
return grammaticalCase === "genitive" ? "чистого долга к оплате" : "чистый долг к оплате";
}
return grammaticalCase === "genitive" ? "чистой дебиторки к получению" : "чистую дебиторку к получению";
}
function appendDebtMirrorCompactDisclosure(lines, snapshot, kind) {
if (snapshot.mirroredOffsetAmount <= 0.005) {
return;
}
lines.push(`Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.`);
lines.push("", "Для сверки:");
lines.push(`- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она не включена в ${debtMirrorCleanScopeLabel(kind, "accusative")}.`);
const leadingMirror = snapshot.mirrorGroups[0] ?? null;
if (leadingMirror) {
lines.push(`Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`);
lines.push(`- Крупнейший встречный контрагент: ${formatDebtMirrorGroupLine(leadingMirror)}`);
}
}
function appendDebtMirrorDisclosure(lines, snapshot, kind) {
@ -2082,8 +2126,11 @@ function appendDebtMirrorDisclosure(lines, snapshot, kind) {
}
lines.push("");
lines.push("Встречные остатки к сверке");
lines.push(`- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она исключена из ${debtMirrorCleanScopeLabel(kind)}.`);
lines.push(...snapshot.mirrorGroups.slice(0, 3).map((item, index) => `${index + 1}. ${formatDebtMirrorGroupLine(item)}`));
lines.push(`- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она исключена из ${debtMirrorCleanScopeLabel(kind, "genitive")}.`);
const leadingMirror = snapshot.mirrorGroups[0] ?? null;
if (leadingMirror) {
lines.push(`- Крупнейший встречный контрагент: ${formatDebtMirrorGroupLine(leadingMirror)}`);
}
}
function deriveOperationalYearWindow(yearDocs, yearOps) {
const docsSeries = [...yearDocs].sort((a, b) => a.year - b.year);
@ -2918,7 +2965,6 @@ function composeFactualReplyBody(intent, rows, options = {}) {
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;
@ -2926,50 +2972,17 @@ function composeFactualReplyBody(intent, rows, options = {}) {
const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const formatForecastMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const lines = [
`Коротко: ориентир по НДС к уплате за ${periodWindowLabel ?? "доступный срез"}${formatForecastMoney(vatToPay)}.`,
`Если смотреть на возможный перенос или переплату, получается ${formatForecastMoney(carryoverOrOverpayment)}.`,
"Это предварительная оценка по оборотам 68.02*/19*, а не подтвержденная сумма налога по декларации.",
"Это предварительная оценка по бухгалтерским оборотам НДС, а не подтвержденная сумма налога по декларации.",
"",
"Что вошло в расчет:",
`- Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`,
`- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`,
`- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`,
`- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`,
`- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.`
`- НДС начисленный: ${formatForecastMoney(turnover68Credit)}.`,
`- Уменьшение/вычеты по НДС: ${formatForecastMoney(turnover68Debit)}.`,
`- Нетто НДС: ${formatForecastMoney(netVat)}.`
];
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
const statusRank = (status) => status === "ok" ? 0 : status === "empty" ? 1 : 2;
const orderedProbeRows = [...vatProbe.probedSources].sort((a, b) => statusRank(a.status) - statusRank(b.status) ||
a.fullName.localeCompare(b.fullName, "ru"));
const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error");
const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6);
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`);
if (visibleProbeRows.length > 0) {
lines.push(...visibleProbeRows.map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
const extra = item.status === "ok"
? item.lastPeriod
? ` | последнее движение: ${item.lastPeriod}`
: ""
: item.status === "error" && item.error
? ` | ошибка: ${item.error}`
: "";
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${extra}`;
}));
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия.");
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*.");
}
if (!vatActivityDetected) {
lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`);
}
@ -3016,16 +3029,13 @@ function composeFactualReplyBody(intent, rows, options = {}) {
const purchaseVat = rowsByMarker.get("VAT_BOOK_PURCHASES") ?? 0;
const netVat = salesVat - purchaseVat;
const vatToPay = Math.max(0, netVat);
const carryoverOrOverpayment = Math.max(0, -netVat);
const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`,
`Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`,
"Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.",
"Расчет сделан по книгам продаж и покупок.",
"",
"Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
@ -3034,21 +3044,6 @@ function composeFactualReplyBody(intent, rows, options = {}) {
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`
];
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`);
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников.");
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия.");
if (vatProbe.errors.length > 0) {
lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
}
if (rows.length === 0) {
lines.push("", "За выбранный налоговый период не найдены строки книг продаж/покупок, поэтому подтвержденная сумма к уплате равна 0.");
}
@ -3104,40 +3099,14 @@ function composeFactualReplyBody(intent, rows, options = {}) {
const lines = [
`Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный срез НДС к уплате по состоянию на дату.",
"",
"Блок 2. Что учтено",
"Что учтено:",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счетам НДС к уплате (68*)."
"- Контур: остатки по счетам НДС к уплате."
];
const vatProbe = options.vatDirectSourceProbe ?? null;
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push("", "Блок 2.1. Проверка VAT-источников в 1С", `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`);
if (vatProbe.probedSources.length > 0) {
lines.push(...vatProbe.probedSources.slice(0, 4).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
const suffix = item.status === "ok"
? `${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.sampleRegistrator ? ` | пример: ${item.sampleRegistrator}` : ""}`
: item.status === "error" && item.error
? ` | ошибка: ${item.error}`
: "";
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${suffix}`;
}));
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Блок 2.1. Проверка VAT-источников в 1С", "- Дополнительная проверка VAT-источников завершилась ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*).");
}
lines.push("", "Блок 3. Сводка", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Блок 4. Подтвержденные позиции");
lines.push("", "Сводка:", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Подтвержденные позиции:");
if (accountRows.length > 0) {
lines.push(...accountRows.slice(0, 12).map((item, index) => {
const refs = Array.from(item.refs).slice(0, 2).join("; ");
return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`;
return `${index + 1}. ${item.account} — остаток НДС к уплате: ${formatMoneyRub(item.total)}, операций: ${formatNumberWithDots(item.operations)}.`;
}));
}
else {
@ -3410,8 +3379,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
: [`Коротко: на ${formatDateRu(payablesAsOfDate)} подтвержденных обязательств к оплате не найдено.`];
if (leading) {
compactLines.push(...confirmedBalances.slice(0, 5).map((item, index) => {
const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : "";
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`;
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.).`;
}));
if (confirmedBalances.length > 5) {
compactLines.push(`Показаны первые 5 из ${formatNumberWithDots(confirmedBalances.length)} подтвержденных позиций.`);
@ -3458,7 +3426,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
lines.push("Подтвержденные позиции к оплате");
if (confirmedBalances.length > 0) {
lines.push(...confirmedBalances.slice(0, 10).flatMap((item, index) => [
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`,
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`,
""
]));
if (lines[lines.length - 1] === "") {
@ -3506,8 +3474,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
: [`Коротко: на ${formatDateRu(receivablesAsOfDate)} подтвержденной дебиторской задолженности не найдено.`];
if (leading) {
compactLines.push(...confirmedBalances.slice(0, 5).map((item, index) => {
const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : "";
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`;
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.).`;
}));
if (confirmedBalances.length > 5) {
compactLines.push(`Показаны первые 5 из ${formatNumberWithDots(confirmedBalances.length)} подтвержденных позиций.`);
@ -3526,44 +3493,27 @@ function composeFactualReplyBody(intent, rows, options = {}) {
};
}
const lines = [
`Коротко: подтвержденная дебиторская задолженность на ${formatDateRu(receivablesAsOfDate)}${formatMoneyRub(totalOutstandingAmount)}.`,
"Это подтвержденный срез дебиторской задолженности, а не эвристический shortlist."
`Коротко: на ${formatDateRu(receivablesAsOfDate)} нам должны ${formatMoneyRub(totalOutstandingAmount)}.`
];
lines.push("");
lines.push("Что учтено");
lines.push(`- Дата среза: ${formatDateRu(receivablesAsOfDate)}.`);
if (periodScopeLine) {
lines.push(periodScopeLine);
}
lines.push("- Контур: дебиторская задолженность по счетам 62/76.");
if (carryoverLine) {
lines.push(carryoverLine);
}
lines.push("");
lines.push("Сводка");
lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`);
lines.push(`- Контрагентов с подтвержденным остатком к получению: ${formatNumberWithDots(confirmedBalances.length)}.`);
appendDebtMirrorDisclosure(lines, balanceSnapshot, "receivables");
lines.push("");
lines.push("Категории дебиторской задолженности");
lines.push(`- ${receivablesCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}.`);
lines.push(`- ${receivablesCategoryLabel("bank_or_credit")}: ${formatNumberWithDots(categoryCounts.bank_or_credit)}.`);
lines.push(`- ${receivablesCategoryLabel("tax_or_state")}: ${formatNumberWithDots(categoryCounts.tax_or_state)}.`);
lines.push(`- ${receivablesCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}.`);
lines.push("");
lines.push("Подтвержденные позиции к получению");
if (confirmedBalances.length > 0) {
lines.push(...confirmedBalances.slice(0, 10).flatMap((item, index) => [
`${index + 1}. ${item.name} | категория: ${receivablesCategoryLabel(item.category)} | остаток к получению: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`,
""
]));
if (lines[lines.length - 1] === "") {
lines.pop();
if (periodScopeLine || carryoverLine) {
lines.push("");
lines.push("Граница ответа:");
if (periodScopeLine) {
lines.push(periodScopeLine);
}
if (carryoverLine) {
lines.push(carryoverLine);
}
}
lines.push("");
lines.push("К получению:");
if (confirmedBalances.length > 0) {
lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.).`));
}
else {
lines.push("- Подтвержденной открытой дебиторской задолженности на дату среза не найдено.");
}
appendDebtMirrorDisclosure(lines, balanceSnapshot, "receivables");
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: joinLines(lines),
@ -3588,7 +3538,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
const carryoverLine = asOfDate || periodFrom || periodTo
? "- В список могут попадать обязательства, возникшие раньше выбранного периода, если они потенциально оставались открытыми на дату среза."
: null;
const formatHeuristicItem = (item, index) => `${index + 1}. ${item.name} | сумма к проверке: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const formatHeuristicItem = (item, index) => `${index + 1}. ${item.name} | сумма к проверке: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const pushCategorySlice = (lines, title, items, limit) => {
if (items.length === 0) {
return;
@ -3685,7 +3635,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
`- ${liabilityCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}`,
"",
"Блок 5. Крупнейшие подтвержденные позиции к оплате (по сумме остатка):",
...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoneyRub(item.outstandingAmount)} | операций в срезе: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`)
...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoneyRub(item.outstandingAmount)} | операций в срезе: ${formatNumberWithDots(item.operations)}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`)
];
appendDebtMirrorDisclosure(lines, balanceSnapshot, "payables");
return {
@ -3931,15 +3881,34 @@ function composeFactualReplyBody(intent, rows, options = {}) {
.filter((item) => Boolean(item)));
const counterparty = resolvePreferredCounterpartyDisplayLabel(options.counterpartyHint, rowCounterparties);
const roleBoundary = bankRoleBoundaryLine(options.userMessage, rows);
const visibleRows = rows.slice(0, Math.min(rows.length, 5));
const visibleRows = [...rows]
.sort((left, right) => Math.abs(right.amount ?? 0) - Math.abs(left.amount ?? 0) ||
(String(right.period ?? "").localeCompare(String(left.period ?? ""), "ru")))
.slice(0, Math.min(rows.length, 5));
const semanticSummary = summarizeBankOperationSemantics(rows);
const compactRoleAnswer = /(?:клиент|поставщик|финансов|роль|коротко)/iu.test(String(options.userMessage ?? ""));
const compactEvidenceRows = visibleRows.map((row, index) => {
const direction = bankOperationDirectionLabel(bankOperationDirection(row));
const amount = formatMoneyRub(row.amount ?? 0);
const period = row.period ? formatDateRu(row.period) : "дата не указана";
const operationKind = String(row.operation_kind ?? "").trim();
const paymentPurpose = String(row.payment_purpose ?? "").trim();
const detail = operationKind || paymentPurpose
? ` | ${[operationKind, paymentPurpose].filter(Boolean).join("; ")}`
: "";
return `${index + 1}. ${period} | ${direction} | ${amount}${detail}`;
});
const lines = [
`Коротко: найдено банковских операций${counterparty ? ` по ${counterparty}` : " по контрагенту"}${rows.length}.`,
summarizeBankOperationDirections(rows),
roleBoundary ?? "Показываю подтвержденные банковские операции из текущего среза.",
bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)),
...formatTopRows(visibleRows, visibleRows.length)
...(semanticSummary ? [semanticSummary] : [])
];
if (rows.length > visibleRows.length) {
if (!compactRoleAnswer) {
lines.push(bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)), "Примеры строк 1С:", ...compactEvidenceRows);
}
lines.push("Следующий шаг: могу отдельно разложить назначения платежа, договоры или отделить банковский контур от клиентского/поставщицкого.");
if (!compactRoleAnswer && rows.length > visibleRows.length) {
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length}; полный список остается в подтвержденном срезе.`);
}
return {

View File

@ -30,6 +30,23 @@ function findFocusedCounterpartyValuePoint(profileRows, counterpartyHint, deps)
}
return profileRows.length === 1 ? profileRows[0] : null;
}
function hasProfitAmbiguityCue(normalizedQuestion) {
return /(?:заработ|прибыл|прибыль|доход|выручк)/iu.test(normalizedQuestion);
}
function buildCashflowBoundaryLine(isSupplier) {
return isSupplier
? "Граница ответа: это подтвержденный денежный поток по поставщику, а не итоговая задолженность."
: "Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.";
}
function buildCashflowNextStepLine(isSupplier, normalizedQuestion) {
if (isSupplier) {
return "Следующий шаг: могу отдельно показать остаток долга, просрочку или расшифровку по документам.";
}
if (hasProfitAmbiguityCue(normalizedQuestion)) {
return "Следующий шаг: могу отдельно проверить чистую прибыль по закрытию 90/91/99.";
}
return "Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.";
}
function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
if (intent === "counterparty_population_and_roles") {
const rowsByMarker = groupRowsByMarker(rows);
@ -396,6 +413,8 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
/(?:клиент|заказчик|покупател|контрагент|customer|client|counterparty|buyer)/iu.test(normalizedQuestion);
const semanticSingleBestCounterparty = focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList;
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
const byCounterparty = new Map();
const byYear = new Map();
const deals = [];
@ -490,10 +509,12 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
: "за доступное время";
const directAnswerLine = isSupplier
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`;
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям.`;
const summaryLines = [
directAnswerLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Контрагент в выборке: ${focusedCounterparty.name}.`,
@ -511,11 +532,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const periodLine = options.periodFrom && options.periodTo
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
const directAnswerLine = isSupplier
? periodLine
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
const summaryLines = [
directAnswerLine,
periodLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Операций в выборке: ${totalOperations}.`,
@ -538,11 +558,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const strongestYear = visible[0];
const directAnswerLine = isSupplier
? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`;
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`;
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (!isSupplier) {
lines.unshift(cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.unshift(cashflowNextStepLine);
}
}
lines.unshift(directAnswerLine);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
}
@ -622,11 +648,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const directAnswerLine = singleCandidateOnly
? isSupplier
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
lines.unshift(directAnswerLine);
if (!isSupplier) {
lines.splice(1, 0, cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.splice(2, 0, cashflowNextStepLine);
}
}
}
lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? item.total / item.ops : 0;

View File

@ -8,6 +8,7 @@ exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFo
exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue;
exports.hasInventoryPurchaseToSaleChainFollowupCue = hasInventoryPurchaseToSaleChainFollowupCue;
exports.hasInventoryPurchaseDateVatBridgeCue = hasInventoryPurchaseDateVatBridgeCue;
exports.hasInventoryMarginRankingFollowupCue = hasInventoryMarginRankingFollowupCue;
exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
exports.runAddressDecomposeStage = runAddressDecomposeStage;
const addressQueryClassifier_1 = require("../addressQueryClassifier");
@ -321,6 +322,7 @@ function isInventoryIntent(intent) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -332,6 +334,7 @@ function isInventoryDrilldownFrameIntent(intent) {
return (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -340,6 +343,7 @@ function isInventoryLifecycleHistoryIntent(intent) {
return (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain");
}
@ -625,11 +629,29 @@ function hasInventoryPurchaseDateVatBridgeCue(text) {
return (/(?:ндс|vat)/iu.test(normalized) &&
/(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(normalized));
}
function hasInventoryMarginRankingFollowupCue(text) {
const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized.trim()) {
return false;
}
const wantsFoundRows = /(?:покажи|показать|выведи|дай|раскрой|show|list|покажи|показать|выведи|дай|раскрой)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себестоимостн|реализац|марж|прибыл|номенклат)/iu.test(normalized);
const account41Not01 = /\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вместо|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
}
function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasInventoryMarginRankingFollowupCue(normalized)) {
return true;
}
if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(normalized)) {
return true;
}
@ -867,6 +889,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@ -919,6 +942,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
if ((intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date")) {
@ -1122,6 +1146,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@ -1134,6 +1159,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const currentContractExplicit = toNonEmptyString(merged.contract);
const currentItemExplicit = toNonEmptyString(merged.item);
const currentAccountExplicit = toNonEmptyString(merged.account);
const currentAccountRefinesMarginDomain = intent === "inventory_margin_ranking_for_nomenclature" &&
hasInventoryMarginRankingFollowupCue(userMessage);
const shouldSuppressGenericPeriodCarryover = (Boolean(currentCounterpartyExplicit) &&
!isLowQualityCounterpartyAnchor(currentCounterpartyExplicit) &&
currentCounterpartyExplicit !== previousCounterparty) ||
@ -1141,7 +1168,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
!isLowQualityContractAnchor(currentContractExplicit) &&
currentContractExplicit !== previousContract) ||
(Boolean(currentItemExplicit) && currentItemExplicit !== previousItem) ||
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount);
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount && !currentAccountRefinesMarginDomain);
const vatRelativeMonthFollowup = relativeMonthFromFollowupYear &&
(intent === "vat_payable_confirmed_as_of_date" ||
intent === "vat_payable_forecast" ||
@ -1189,6 +1216,19 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
reasons.push("period_from_followup_context");
}
if (intent === "inventory_margin_ranking_for_nomenclature" &&
previousHasPeriod &&
hasInventoryMarginRankingFollowupCue(userMessage) &&
!hasExplicitPeriodInMessage &&
!hasExplicitCurrentDateInMessage) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
}
if (!currentHasPeriod &&
previousHasPeriod &&
hasFollowupSignal &&
@ -1251,6 +1291,7 @@ function resolveMissingRequiredFilters(intent, filters) {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
inventory_on_hand_as_of_date: ["as_of_date"],
inventory_margin_ranking_for_nomenclature: ["period_from", "period_to"],
inventory_profitability_for_item: ["item"],
open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
@ -1319,6 +1360,22 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test(normalizedMessage) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage);
const staleInventoryLineageCanYieldToCounterparty = previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
const inventoryPurchaseDateVatBridge = inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
const marginRankingLineageActive = sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
followupContext.root_intent === "inventory_margin_ranking_for_nomenclature";
if (marginRankingLineageActive &&
hasInventoryMarginRankingFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "documents_forming_balance" ||
detectedIntent.intent === "inventory_margin_ranking_for_nomenclature" ||
detectedIntent.intent === sourceIntent)) {
return {
intent: "inventory_margin_ranking_for_nomenclature",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_margin_ranking_followup_context"]
};
}
if (inventoryPurchaseDateVatBridge &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === sourceIntent ||

View File

@ -81,21 +81,76 @@ function inventoryProfitabilityPeriodLabel(options, deps) {
const asOfDate = typeof options.asOfDate === "string" && options.asOfDate.trim().length > 0 ? options.asOfDate : null;
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
function asksForInventoryCostBaseRows(userMessage) {
const text = String(userMessage ?? "").toLowerCase();
if (!/(?:покажи|показать|выведи|вывести|дай|дать|раскрой|раскрыть|строк|строки|строку|баз)/iu.test(text)) {
return false;
}
return /(?:себестоимостн|себестоимост|себестоим|закупочн|закупк|90\.02|\b41\b|баз)/iu.test(text);
}
function inventoryRowItemLabel(row, deps) {
return deps.summarizeInventoryTraceRows([row]).item;
}
function buildInventoryMarginRankingEntries(rows, deps) {
const byItem = new Map();
for (const row of rows) {
const item = inventoryRowItemLabel(row, deps);
if (!item) {
continue;
}
const key = item.trim().toLocaleLowerCase("ru");
const current = byItem.get(key) ?? { item, saleRows: [], purchaseRows: [] };
if (deps.isInventorySaleMovement(row)) {
current.saleRows.push(row);
}
if (deps.isInventoryPurchaseMovement(row)) {
current.purchaseRows.push(row);
}
byItem.set(key, current);
}
return Array.from(byItem.values())
.map((entry) => {
const revenue = sumInventoryRowAmount(entry.saleRows);
const costProxy = sumInventoryRowAmount(entry.purchaseRows);
const spread = revenue - costProxy;
return {
item: entry.item,
revenue,
costProxy,
spread,
marginPct: revenue > 0 ? (spread / revenue) * 100 : null,
saleQuantity: sumInventoryRowQuantity(entry.saleRows),
purchaseQuantity: sumInventoryRowQuantity(entry.purchaseRows),
saleDocuments: entry.saleRows.length,
purchaseDocuments: entry.purchaseRows.length
};
})
.filter((entry) => entry.revenue > 0 || entry.costProxy > 0);
}
function formatInventoryMarginRankingLine(entry, index, deps) {
return `${index + 1}. ${entry.item} — выручка ${deps.formatMoneyRub(entry.revenue)}, себестоимостная база ${deps.formatMoneyRub(entry.costProxy)}, валовая разница ${deps.formatMoneyRub(entry.spread)}, маржа ${formatInventoryPercent(entry.marginPct, deps.formatNumberWithDots)}.`;
}
function composeInventoryReply(intent, rows, options, deps) {
if (intent === "inventory_on_hand_as_of_date") {
const asOfDate = deps.resolvePayablesAsOfDate(options);
const positions = deps.buildInventoryOnHandAggregate(rows, asOfDate);
const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item));
const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const directAnswerLine = positions.length > 0
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.`
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines = [directAnswerLine];
if (positions.length > 0) {
const visiblePositionsLimit = 6;
const visiblePositions = positions.slice(0, visiblePositionsLimit);
const positionsByAmount = [...positions].sort((left, right) => {
const amountDelta = right.amount - left.amount;
if (amountDelta !== 0) {
return amountDelta;
}
return left.item.localeCompare(right.item, "ru");
});
const visiblePositions = positionsByAmount.slice(0, visiblePositionsLimit);
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", visiblePositions.map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, {
formatDateRu: deps.formatDateRu,
formatNumberWithDots: deps.formatNumberWithDots,
@ -115,11 +170,14 @@ function composeInventoryReply(intent, rows, options, deps) {
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
`Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
]);
if (rows.length !== positions.length) {
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
}
if (positions.length > 0) {
lines.push("- Следующий шаг: могу раскрыть полный список, разложить остатки по складам или сравнить с другой датой.");
}
return positions.length > 0
? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong"))
: (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium"));
@ -392,6 +450,91 @@ function composeInventoryReply(intent, rows, options, deps) {
? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(summary.counterparties.length > 0 ? "strong" : "medium", true))
: (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false));
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
const entries = buildInventoryMarginRankingEntries(rows, deps);
const confirmedEntries = entries.filter((entry) => entry.revenue > 0 && entry.costProxy > 0);
const highMargin = [...confirmedEntries]
.sort((left, right) => right.spread - left.spread || (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity))
.slice(0, 5);
const lowMargin = [...confirmedEntries]
.sort((left, right) => left.spread - right.spread || (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity))
.slice(0, 5);
const salesWithoutCost = entries.filter((entry) => entry.revenue > 0 && entry.costProxy <= 0);
const purchasesWithoutSales = entries.filter((entry) => entry.costProxy > 0 && entry.revenue <= 0);
const periodLabel = inventoryProfitabilityPeriodLabel(options, deps);
const totalRevenue = entries.reduce((sum, entry) => sum + entry.revenue, 0);
const totalCostProxy = entries.reduce((sum, entry) => sum + entry.costProxy, 0);
const totalSpread = totalRevenue - totalCostProxy;
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines = [
costBaseRowsRequested && purchasesWithoutSales.length === 0
? `За период ${periodLabel} подтвержденных строк себестоимостной базы по реализованной номенклатуре не найдено.`
: `За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`
];
const findings = [];
if (salesWithoutCost.length > 0) {
const salesCount = deps.formatNumberWithDots(salesWithoutCost.length);
const salesItemPhrase = salesWithoutCost.length === 1 ? "1 номенклатурной позиции" : `${salesCount} номенклатурным позициям`;
findings.push(`Есть реализация по ${salesItemPhrase}.`);
findings.push(salesWithoutCost.length === 1
? "Подтвержденной себестоимости реализации по этой позиции не найдено."
: "Подтвержденной себестоимости реализации по этим позициям не найдено.");
findings.push("Поэтому валовую прибыль и маржинальность честно посчитать нельзя.");
}
if (costBaseRowsRequested && purchasesWithoutSales.length === 0) {
findings.push("Строк себестоимости реализации / себестоимостной базы для показа нет.");
}
if (purchasesWithoutSales.length > 0) {
const purchaseCount = deps.formatNumberWithDots(purchasesWithoutSales.length);
const purchaseItemPhrase = purchasesWithoutSales.length === 1 ? "1 позиции" : `${purchaseCount} позициям`;
findings.push(purchasesWithoutSales.length === 1
? `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ней в периоде не найдено.`
: `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ним в периоде не найдено.`);
}
if (entries.length === 0) {
findings.push("В доступной выборке нет достаточных строк реализации и себестоимости по номенклатуре.");
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что нашлось:", findings);
lines.push(`Вывод: за период ${periodLabel} нет достаточной базы для рейтинга «высокая / низкая прибыль» по номенклатуре.`);
const nextActions = [];
if (salesWithoutCost.length > 0) {
nextActions.push("показать найденные реализации за этот период;");
}
if (purchasesWithoutSales.length > 0) {
nextActions.push("показать найденные строки себестоимостной базы за этот период;");
}
nextActions.push("расширить период до квартала или года;", "попробовать строгий расчет по проводкам 90.01 / 90.02;", "построить управленческий proxy по закупочным документам, если такой способ допустим для вашей проверки.");
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что можно сделать дальше:", nextActions);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Граница ответа:", [
"Прибыльность номенклатуры считаю только когда есть реализация и подтвержденная себестоимость реализации.",
"Это не показатель чистой прибыли и не замена закрытию месяца."
]);
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(entries.length > 0 ? "medium" : "weak", false));
}
const directAnswerLine = confirmedEntries.length > 0
? `За период ${periodLabel} собран рейтинг реализованной номенклатуры по валовой маржинальности: выручка ${deps.formatMoneyRub(totalRevenue)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, расчетная валовая разница ${deps.formatMoneyRub(totalSpread)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines = [directAnswerLine];
if (highMargin.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Высокая валовая маржинальность:", highMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps)));
}
if (lowMargin.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Низкая или отрицательная валовая маржинальность:", lowMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps)));
}
const boundaryLines = [
"Это управленческий расчет валовой маржинальности по реализации и доступной себестоимостной базе, не показатель чистой прибыли.",
"Для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и закрытие себестоимости; этот ответ не подменяет закрытие месяца."
];
if (salesWithoutCost.length > 0) {
boundaryLines.push(`По ${deps.formatNumberWithDots(salesWithoutCost.length)} позициям есть продажи, но нет подтвержденной себестоимости реализации — их нельзя честно ранжировать по прибыли.`);
}
if (purchasesWithoutSales.length > 0) {
boundaryLines.push(`По ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в этом периоде.`);
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Граница ответа:", boundaryLines);
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(confirmedEntries.length > 0 ? "strong" : entries.length > 0 ? "medium" : "weak", confirmedEntries.length > 0));
}
if (intent === "inventory_profitability_for_item") {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));

View File

@ -14,6 +14,11 @@ function sessionOrganizationName(sessionOrganizationScope, toNonEmptyString) {
const scope = toRecordObject(sessionOrganizationScope);
return toNonEmptyString(scope?.selectedOrganization) ?? toNonEmptyString(scope?.activeOrganization);
}
function sessionKnownOrganizations(sessionOrganizationScope) {
const scope = toRecordObject(sessionOrganizationScope);
const knownOrganizations = scope?.knownOrganizations;
return Array.isArray(knownOrganizations) ? knownOrganizations : [];
}
function predecomposeOrganizationName(predecomposeContract, toNonEmptyString) {
const entities = toRecordObject(predecomposeContract?.entities);
return (toNonEmptyString(entities?.organization) ??
@ -46,6 +51,106 @@ function mergeOrganizationIntoDiscoveryFollowupContext(followupContext, organiza
}
return base;
}
function compactLower(value) {
return String(value ?? "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function hasCompactCashflowFollowupSignal(text) {
const value = compactLower(text);
if (!value) {
return false;
}
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b)/iu.test(value);
}
function dateScopeToFilterWindow(dateScope) {
if (!dateScope) {
return null;
}
if (/^(?:19|20)\d{2}$/.test(dateScope)) {
return {
period_from: `${dateScope}-01-01`,
period_to: `${dateScope}-12-31`
};
}
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(dateScope)) {
return { as_of_date: dateScope };
}
return null;
}
function looksLikeReliableOrganizationScope(value) {
const text = compactLower(value);
if (!text) {
return false;
}
if (/(?:\u0440\u0430\u0437\u0431\u0438\u0432|\u0440\u0430\u0437\u0440\u0435\u0437|\u0432\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043d\u0435\u0442\u0442\u043e|\u0442\u043e\u043f)/iu.test(text)) {
return false;
}
return /(?:^|[\s"'\u00ab])(?:\u043e\u043e\u043e|\u0438\u043f|\u043f\u0430\u043e|\u0430\u043e|\u0437\u0430\u043e)(?:[\s"'\u00bb]|$)|\u043e\u0431\u0449\u0435\u0441\u0442\u0432\p{L}*\s+\u0441\s+\u043e\u0433\u0440\u0430\u043d\u0438\u0447\p{L}*\s+\u043e\u0442\u0432\p{L}*|\b(?:llc|inc|corp)\b/iu.test(text);
}
function inferBusinessOverviewDiscoveryContextFromSessionItems(sessionItems, toNonEmptyString) {
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
const item = toRecordObject(sessionItems[index]);
if (toNonEmptyString(item?.role) !== "assistant") {
continue;
}
const debug = toRecordObject(item?.debug);
const entryPoint = toRecordObject(debug?.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input);
const dataNeedGraph = toRecordObject(turnInput?.data_need_graph);
if (toNonEmptyString(dataNeedGraph?.business_fact_family) !== "business_overview") {
continue;
}
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref);
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope));
if (!filterWindow) {
continue;
}
const previousFilters = { ...filterWindow };
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope);
if (looksLikeReliableOrganizationScope(organization)) {
previousFilters.organization = organization;
}
const rankingNeed = toNonEmptyString(dataNeedGraph?.ranking_need);
return {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: previousFilters,
root_filters: { ...previousFilters },
previous_seeded_ranking_need: rankingNeed,
previous_intent: "business_overview",
root_intent: "business_overview"
};
}
return null;
}
function mergeBusinessOverviewDateContextForCompactCashflow(input) {
if (!hasCompactCashflowFollowupSignal(input.userMessage)) {
return input.followupContext;
}
const existingFilters = toRecordObject(input.followupContext?.previous_filters);
if (input.toNonEmptyString(existingFilters?.period_from) ||
input.toNonEmptyString(existingFilters?.period_to) ||
input.toNonEmptyString(existingFilters?.as_of_date)) {
return input.followupContext;
}
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
if (!inferred) {
return input.followupContext;
}
return {
...inferred,
...(input.followupContext ?? {}),
previous_filters: {
...(toRecordObject(inferred.previous_filters) ?? {}),
...(toRecordObject(input.followupContext?.previous_filters) ?? {})
},
root_filters: {
...(toRecordObject(inferred.root_filters) ?? {}),
...(toRecordObject(input.followupContext?.root_filters) ?? {})
}
};
}
function hasSelectedObjectInventorySignal(text) {
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ним|selected\s+object)/iu.test(String(text ?? ""));
}
@ -61,6 +166,7 @@ function isInventorySelectedObjectOrRootIntent(intent) {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date");
@ -74,6 +180,15 @@ function isGenericCanonicalDriftIntent(intent) {
intent === "bank_operations_by_contract" ||
intent === "documents_forming_balance");
}
function hasInventoryMarginRankingAccountCorrectionCue(text) {
const value = String(text ?? "").toLowerCase();
if (!value.trim()) {
return false;
}
return (/\b41(?:[.,]\d{1,2})?\b/iu.test(value) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(value) &&
/(?:\u0430\s+\u043d\u0435|\u043d\u0435|\u0432\u043c\u0435\u0441\u0442\u043e|not|instead)/iu.test(value));
}
function hasSameDateFollowupSignal(text) {
return /(?:эту\s+же\s+дат(?:у|е|ой)|ту\s+же\s+дат(?:у|е|ой)|same\s+date)/iu.test(String(text ?? ""));
}
@ -117,6 +232,10 @@ function shouldPreferRawFollowupMessage(userMessage, addressInputMessage, carryo
const hasInventoryItemCarryover = previousAnchorType === "item" && isInventorySelectedObjectOrRootIntent(previousIntent);
const hasInventoryFrameCarryover = isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
const hasInventoryMarginRankingCarryover = previousIntent === "inventory_margin_ranking_for_nomenclature" ||
rootIntent === "inventory_margin_ranking_for_nomenclature";
const hasInventoryMarginRankingAccountCorrection = hasInventoryMarginRankingCarryover &&
[rawMessage, canonicalMessage].some((message) => hasInventoryMarginRankingAccountCorrectionCue(message));
const hasDocumentCarryover = previousIntent === "list_documents_by_counterparty" || previousIntent === "list_documents_by_contract";
if (mode === "unsupported" && intent === "unknown") {
return true;
@ -132,6 +251,10 @@ function shouldPreferRawFollowupMessage(userMessage, addressInputMessage, carryo
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")) {
return true;
}
if (hasInventoryMarginRankingAccountCorrection &&
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")) {
return true;
}
return ((hasSelectedObjectInventorySignal(rawMessage) || hasInventoryItemCarryover) &&
(hasSelectedObjectInventoryActionCue(rawMessage) || hasShortInventoryPurchaseFollowupCue(rawMessage)) &&
(isGenericCanonicalDriftIntent(intent) || intent === "unknown"));
@ -192,9 +315,15 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
const orchestrationContract = toRecordObject(orchestrationDecision.orchestrationContract);
const predecomposeContract = toRecordObject(addressPreDecompose.predecomposeContract);
const explicitPredecomposeOrganization = predecomposeOrganizationName(predecomposeContract, input.toNonEmptyString);
const discoveryFollowupContext = mergeOrganizationIntoDiscoveryFollowupContext(followupContext, explicitPredecomposeOrganization
const discoveryFollowupContextWithOrganization = mergeOrganizationIntoDiscoveryFollowupContext(followupContext, explicitPredecomposeOrganization
? null
: sessionOrganizationName(input.sessionOrganizationScope ?? null, input.toNonEmptyString));
const discoveryFollowupContext = mergeBusinessOverviewDateContextForCompactCashflow({
userMessage: input.userMessage,
followupContext: discoveryFollowupContextWithOrganization,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose);
const runDiscoveryEntryPoint = input.runMcpDiscoveryRuntimeEntryPoint ?? assistantMcpDiscoveryRuntimeEntryPoint_1.runAssistantMcpDiscoveryRuntimeEntryPoint;
let mcpDiscoveryRuntimeEntryPoint = null;
@ -205,7 +334,8 @@ async function buildAssistantAddressOrchestrationRuntime(input) {
effectiveMessage: addressInputMessage,
assistantTurnMeaning: toRecordObject(orchestrationContract?.assistant_turn_meaning),
predecomposeContract,
followupContext: discoveryFollowupContext
followupContext: discoveryFollowupContext,
knownOrganizations: sessionKnownOrganizations(input.sessionOrganizationScope ?? null)
}));
}
catch (error) {

View File

@ -8,6 +8,13 @@ function formatMissingAnchors(anchors) {
return anchors.join(", ");
}
function buildClarificationReply(binding) {
if (binding.capability_contract_id === "inventory_inventory_margin_ranking_for_nomenclature") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
return [
"Нужно уточнение, чтобы не подставить неподтвержденный объект в расчет.",
`Не хватает: ${formatMissingAnchors(binding.missing_anchors)}.`,

View File

@ -110,6 +110,13 @@ function anchorSatisfied(requiredAnchor, providedAnchors, debug) {
if (providedAnchors.includes(requiredAnchor)) {
return true;
}
if (requiredAnchor === "period") {
return ((hasValue(filters?.period_from) && hasValue(filters?.period_to)) ||
hasValue(filters?.as_of_date) ||
providedAnchors.includes("period_from") ||
providedAnchors.includes("period_to") ||
providedAnchors.includes("as_of_date"));
}
if (requiredAnchor === "item") {
return (providedAnchors.includes("selected_object") ||
providedAnchors.includes("anchor_item") ||

View File

@ -0,0 +1,181 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_EVIDENCE_PLANNER_SCHEMA_VERSION = void 0;
exports.buildAssistantEvidencePlanner = buildAssistantEvidencePlanner;
exports.ASSISTANT_EVIDENCE_PLANNER_SCHEMA_VERSION = "assistant_evidence_planner_v1";
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function normalizeReasonCode(value) {
const normalized = value
.trim()
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized.length > 0 ? normalized.slice(0, 120) : null;
}
function pushReason(target, value) {
const normalized = normalizeReasonCode(value);
if (normalized && !target.includes(normalized)) {
target.push(normalized);
}
}
function uniqueStrings(values) {
const result = [];
for (const value of values) {
const text = toNonEmptyString(value);
if (text && !result.includes(text)) {
result.push(text);
}
}
return result;
}
function providedAxesFromMeaning(meaning) {
const result = [];
if ((meaning?.explicit_entity_candidates?.length ?? 0) > 0) {
result.push("counterparty");
result.push("business_entity");
}
if (toNonEmptyString(meaning?.explicit_organization_scope)) {
result.push("organization");
}
if (toNonEmptyString(meaning?.explicit_date_scope)) {
result.push("period");
}
if (toNonEmptyString(meaning?.asked_aggregation_axis)) {
result.push("aggregate_axis");
}
if (toNonEmptyString(meaning?.metadata_scope_hint)) {
result.push("metadata_scope");
}
return uniqueStrings(result);
}
function missingAxes(requiredAxes, providedAxes) {
return requiredAxes.filter((axis) => !providedAxes.includes(axis));
}
const USER_ACTIONABLE_AXIS_SET = new Set([
"counterparty",
"business_entity",
"organization",
"period",
"as_of_date",
"item",
"supplier",
"buyer",
"warehouse",
"document",
"contract",
"metadata_scope",
"lane_family_choice"
]);
function userActionableMissingAxes(axes) {
return axes.filter((axis) => USER_ACTIONABLE_AXIS_SET.has(axis));
}
function coverageExpectationFor(graph) {
if (graph?.proof_expectation === "bounded_inference") {
return "bounded_inference";
}
if (graph?.proof_expectation === "clarification_required") {
return "clarification";
}
return "confirmed_coverage";
}
function answerModeFor(input) {
if (input.plannerStatus === "needs_clarification") {
return "clarification_required";
}
if (input.plannerStatus === "blocked") {
return "checked_sources_only";
}
if (input.coverageExpectation === "bounded_inference") {
return "bounded_business_inference";
}
if (input.coverageExpectation === "clarification") {
return "clarification_required";
}
return "confirmed_business_answer";
}
function requiredUserLayersFor(answerMode) {
if (answerMode === "clarification_required") {
return ["clarifying_question", "why_needed", "available_calculation"];
}
if (answerMode === "bounded_business_inference") {
return ["business_conclusion", "key_figures", "evidence_boundary", "next_step"];
}
if (answerMode === "checked_sources_only") {
return ["checked_sources_boundary", "unknowns", "next_probe"];
}
return ["direct_business_answer", "key_figures", "evidence_boundary", "next_step"];
}
function buildAssistantEvidencePlanner(input) {
const graph = input.dataNeedGraph ?? null;
const plan = input.discoveryPlan;
const turnMeaning = plan.turn_meaning_ref;
const requiredAxes = uniqueStrings(plan.required_axes);
const providedAxes = providedAxesFromMeaning(turnMeaning);
const graphClarificationGaps = uniqueStrings(graph?.clarification_gaps ?? []);
const additionalAxisGaps = uniqueStrings(input.additionalMissingAxes ?? []).filter((axis) => !providedAxes.includes(axis) && (requiredAxes.includes(axis) || USER_ACTIONABLE_AXIS_SET.has(axis)));
const axisGaps = uniqueStrings([...additionalAxisGaps, ...missingAxes(requiredAxes, providedAxes)]);
const actionableAxisGaps = userActionableMissingAxes(axisGaps);
const clarificationGaps = uniqueStrings([...graphClarificationGaps, ...axisGaps]);
const coverageExpectation = coverageExpectationFor(graph);
const answerMode = answerModeFor({
plannerStatus: input.plannerStatus,
coverageExpectation
});
const reasonCodes = uniqueStrings(plan.reason_codes);
pushReason(reasonCodes, "evidence_planner_contract_built");
if (graph) {
pushReason(reasonCodes, "evidence_planner_consumed_data_need_graph");
}
if (clarificationGaps.length > 0) {
pushReason(reasonCodes, "evidence_planner_has_missing_axes_or_gaps");
}
return {
schema_version: exports.ASSISTANT_EVIDENCE_PLANNER_SCHEMA_VERSION,
policy_owner: "assistantEvidencePlanner",
planner_status: input.plannerStatus,
semantic_data_need: toNonEmptyString(input.semanticDataNeed),
selected_chain_id: input.selectedChainId,
data_need: {
business_fact_family: graph?.business_fact_family ?? null,
action_family: graph?.action_family ?? null,
aggregation_need: graph?.aggregation_need ?? null,
comparison_need: graph?.comparison_need ?? null,
ranking_need: graph?.ranking_need ?? null,
time_scope_need: graph?.time_scope_need ?? null,
proof_expectation: graph?.proof_expectation ?? null,
subject_candidates: uniqueStrings(graph?.subject_candidates ?? [])
},
evidence_axes: {
required_axes: requiredAxes,
provided_axes: providedAxes,
missing_axes: axisGaps,
user_actionable_missing_axes: actionableAxisGaps,
clarification_gaps: clarificationGaps
},
primitive_plan: {
selected_chain_id: input.selectedChainId,
allowed_primitives: plan.allowed_primitives,
rejected_primitives: plan.rejected_primitives,
execution_budget: plan.execution_budget
},
coverage_gate: {
requires_evidence_gate: true,
expected_coverage: coverageExpectation,
answer_permission_if_satisfied: answerMode,
answer_may_use_raw_model_claims: false
},
answer_contract: {
answer_mode: answerMode,
required_user_layers: requiredUserLayersFor(answerMode),
forbidden_overclaim_flags: uniqueStrings(graph?.forbidden_overclaim_flags ?? []),
must_keep_internal_mechanics_hidden: true
},
reason_codes: reasonCodes
};
}

View File

@ -1212,22 +1212,16 @@ function derivedValueFlowConfirmedLine(pilot) {
const organization = organizationScope ? ` по организации ${organizationScope}` : "";
const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : "";
const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне";
const movementLabel = flow.value_flow_direction === "outgoing_supplier_payout"
? "исходящих платежей/списаний"
: "входящих денежных поступлений";
const totalLabel = flow.value_flow_direction === "outgoing_supplier_payout"
? "сумма исходящих платежей/списаний составляет"
: "сумма входящих денежных поступлений составляет";
? "исходящие платежи/списания"
: "входящие денежные поступления";
const caveat = flow.value_flow_direction === "outgoing_supplier_payout"
? "Это расчет по найденным строкам 1С, а не подтверждение полного объема платежей вне проверенного окна."
: "Это расчет по найденным строкам 1С, а не подтверждение полного объема поступлений вне проверенного окна.";
const dates = flow.first_movement_date && flow.latest_movement_date
? ` Первая найденная дата движения: ${flow.first_movement_date}; последняя: ${flow.latest_movement_date}.`
: "";
? "Граница: это проверенный денежный срез по найденным списаниям в 1С, не внешний аудит всех платежей вне этого окна."
: "Граница: это проверенный денежный срез по найденным поступлениям в 1С, не внешний аудит всех поступлений вне этого окна.";
const limitCaveat = flow.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; полный запрошенный период может быть покрыт не полностью."
? " Проверка уперлась в лимит строк, поэтому полный период может быть покрыт не полностью."
: "";
return `По найденным строкам ${movementLabel} в 1С${counterparty}${period} ${totalLabel} ${flow.total_amount_human_ru} Учтено строк с суммой: ${flow.rows_with_amount} из ${flow.rows_matched}.${dates}${limitCaveat} ${caveat}`;
return `${totalLabel}${counterparty || organization}${period}: ${flow.total_amount_human_ru}.${limitCaveat} ${caveat}`;
}
function derivedValueFlowMonthlyLines(pilot) {
const flow = pilot.derived_value_flow;
@ -1831,7 +1825,7 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
: pilot.derived_ranked_value_flow && derivedValueLine
? [derivedValueLine]
: derivedValueLine
? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines]
? [derivedValueLine, ...monthlyConfirmedLines]
: valueFlowZeroResultConfirmedLine(pilot)
? [valueFlowZeroResultConfirmedLine(pilot)]
: derivedEntityResolutionLine

View File

@ -115,6 +115,12 @@ function hasBusinessOverviewDirectMoneyAnswerHint(input) {
return true;
}
const text = input.rawUtterance;
if (/(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(text)) {
return true;
}
if (/(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u0434\u0435\u043d\p{L}{0,20}\s+\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\u043b\p{L}*[\s\S]{0,80}\u0443\u0448\u043b\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u0432\u0445\u043e\u0434\u044f\u0449\p{L}*[\s\S]{0,80}\u0438\u0441\u0445\u043e\u0434\u044f\u0449\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e)/iu.test(text)) {
return true;
}
return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|how\s+much)[\s\S]{0,120}(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u0435\u043d\p{L}*|\u043f\u043e\u043b\u0443\u0447|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*)|(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447)[\s\S]{0,120}(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u0432\u0441\u0435\u0433\u043e|\u0432\u043e\u043e\u0431\u0449\u0435|(?:19|20)\d{2}|all\s+time)|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|which|what)[\s\S]{0,80}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,80}(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,40}(?:\u0433\u043e\u0434|year)/iu.test(text);
}
function timeScopeNeedFor(input) {
@ -193,6 +199,12 @@ function rankingNeedFromRawUtterance(value) {
if (!text) {
return null;
}
if (/\u0438\u0441\u043a\u043b\u044e\u0447[\p{L}\p{N}_]*\s+\u0442\u043e\u043f/iu.test(text)) {
return null;
}
if (/(?:\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u043d\u0435\s+\u0442\u043e\u043f\b|\u0438\u0441\u043a\u043b\u044e\u0447\w*\s+\u0442\u043e\u043f|\u0431\u0435\u0437\s+\u0440\u0435\u0439\u0442\u0438\u043d\u0433\u0430\b|без\s+топ(?:ов|а)?\b|не\s+топ\b|исключ\S*\s+топ|без\s+рейтинга\b)/iu.test(text)) {
return null;
}
if (/(?:\u0442\u043e\u043f[-\s]?\d*|\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)|\u0431\u043e\u043b\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u0431\u043e\u043b[\u0435\u0451]\u0435|\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c|\u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448|\u043b\u0443\u0447\u0448\u0438\u0439)/iu.test(text)) {
return "top_desc";
}
@ -219,6 +231,13 @@ function rankingNeedFromRawUtterance(value) {
}
return null;
}
function suppressRankingNeedFromRawUtterance(value) {
const text = lower(value);
if (!text) {
return false;
}
return /(?:\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u043d\u0435\s+\u0442\u043e\u043f\b|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440\b|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0438\u0441\u043a\u043b\u044e\u0447\w*\s+\u0442\u043e\u043f|\u0431\u0435\u0437\s+\u0440\u0435\u0439\u0442\u0438\u043d\u0433\u0430\b|без\s+топ(?:ов|а)?\b|не\s+топ\b|не\s+обзор\b|просто\s+ден\p{L}+|исключ\S*\s+топ|без\s+рейтинга\b)/iu.test(text);
}
function proofExpectationFor(input) {
if (input.clarificationGaps.length > 0) {
return "clarification_required";
@ -377,6 +396,7 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
const action = lower(turnMeaning?.asked_action_family);
const unsupported = lower(turnMeaning?.unsupported_but_understood_family);
const rawUtterance = lower(input.rawUtterance);
const rawQuestionSignal = lower([input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" "));
const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis);
const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need);
const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope);
@ -394,15 +414,18 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
});
const aggregationNeed = aggregationNeedFor(aggregationAxis);
const comparisonNeed = comparisonNeedFor(action);
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed;
const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance);
const subjectScopedBidirectionalAllTime = businessFactFamily === "value_flow" &&
comparisonNeed === "incoming_vs_outgoing" &&
subjectCandidates.length > 0 &&
!explicitDateScope;
const suppressRankingNeed = suppressRankingNeedFromRawUtterance(rawQuestionSignal) ||
/(?:\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?|\u043d\u0435\s+\u0442\u043e\u043f|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0438\u0441\u043a\u043b\u044e\u0447\w*\s+\u0442\u043e\u043f|\u0431\u0435\u0437\s+\u0440\u0435\u0439\u0442\u0438\u043d\u0433\u0430)/iu.test(rawQuestionSignal);
const rawRankingNeed = rankingNeedFromRawUtterance(rawQuestionSignal);
const rankingNeed = suppressRankingNeed ? null : rawRankingNeed ?? seededRankingNeed;
const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({
family: businessFactFamily,
rawUtterance,
rawUtterance: rawQuestionSignal,
rankingNeed
});
const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action);

View File

@ -38,6 +38,15 @@ function isRouteCandidateContract(value) {
return (record?.schema_version === "assistant_mcp_route_candidate_v1" &&
record?.policy_owner === "assistantMcpDiscoveryRuntimeBridge");
}
function isEvidencePlannerContract(value) {
const record = toRecordObject(value);
return record?.schema_version === "assistant_evidence_planner_v1" && record?.policy_owner === "assistantEvidencePlanner";
}
function isExecutionHandoffContract(value) {
const record = toRecordObject(value);
return (record?.schema_version === "assistant_mcp_discovery_execution_handoff_v1" &&
record?.policy_owner === "assistantMcpDiscoveryExecutionHandoff");
}
function resolveEntryPoint(input) {
if (isMcpDiscoveryEntryPointContract(input.entryPoint)) {
return input.entryPoint;
@ -51,16 +60,29 @@ function buildAssistantMcpDiscoveryDebugAttachmentFields(input) {
const entryPoint = resolveEntryPoint(input);
const bridge = toRecordObject(entryPoint?.bridge);
const planner = toRecordObject(bridge?.planner);
const evidencePlan = isEvidencePlannerContract(planner?.evidence_plan) ? planner.evidence_plan : null;
const evidencePlanAxes = toRecordObject(evidencePlan?.evidence_axes);
const evidencePlanCoverageGate = toRecordObject(evidencePlan?.coverage_gate);
const evidencePlanAnswerContract = toRecordObject(evidencePlan?.answer_contract);
const chainAlignment = toRecordObject(planner?.catalog_chain_template_alignment);
const routeCandidate = isRouteCandidateContract(bridge?.route_candidate) ? bridge.route_candidate : null;
const executionHandoff = isExecutionHandoffContract(bridge?.execution_handoff) ? bridge.execution_handoff : null;
const answerDraft = toRecordObject(bridge?.answer_draft);
const hotRuntimeWired = entryPoint?.hot_runtime_wired === true ||
bridge?.hot_runtime_wired === true ||
executionHandoff?.can_use_guarded_response === true;
return {
assistant_mcp_discovery_entry_point_v1: entryPoint,
mcp_discovery_entry_status: toNonEmptyString(entryPoint?.entry_status),
mcp_discovery_attempted: Boolean(entryPoint?.discovery_attempted),
mcp_discovery_hot_runtime_wired: false,
mcp_discovery_hot_runtime_wired: hotRuntimeWired,
mcp_discovery_bridge_status: toNonEmptyString(bridge?.bridge_status),
mcp_discovery_selected_chain_id: toNonEmptyString(planner?.selected_chain_id),
mcp_discovery_evidence_plan_v1: evidencePlan,
mcp_discovery_evidence_plan_status: toNonEmptyString(evidencePlan?.planner_status),
mcp_discovery_evidence_plan_answer_mode: toNonEmptyString(evidencePlanAnswerContract?.answer_mode),
mcp_discovery_evidence_plan_missing_axes: toStringArray(evidencePlanAxes?.missing_axes),
mcp_discovery_evidence_plan_expected_coverage: toNonEmptyString(evidencePlanCoverageGate?.expected_coverage),
mcp_discovery_catalog_chain_template_matches: toStringArray(planner?.catalog_chain_template_matches),
mcp_discovery_catalog_chain_alignment_status: toNonEmptyString(chainAlignment?.alignment_status),
mcp_discovery_catalog_chain_top_match: toNonEmptyString(chainAlignment?.top_chain_template_match),
@ -70,11 +92,18 @@ function buildAssistantMcpDiscoveryDebugAttachmentFields(input) {
mcp_discovery_route_candidate_fact_family: toNonEmptyString(routeCandidate?.business_fact_family),
mcp_discovery_route_candidate_action_family: toNonEmptyString(routeCandidate?.action_family),
mcp_discovery_route_candidate_proof_expectation: toNonEmptyString(routeCandidate?.proof_expectation),
mcp_discovery_route_candidate_evidence_plan_status: toNonEmptyString(routeCandidate?.evidence_plan_status),
mcp_discovery_route_candidate_evidence_answer_mode: toNonEmptyString(routeCandidate?.evidence_answer_mode),
mcp_discovery_route_candidate_evidence_expected_coverage: toNonEmptyString(routeCandidate?.evidence_expected_coverage),
mcp_discovery_route_candidate_missing_axes: toStringArray(routeCandidate?.missing_axes),
mcp_discovery_route_candidate_provided_axes: toStringArray(routeCandidate?.provided_axes),
mcp_discovery_route_candidate_executable_now: routeCandidate?.executable_now === true,
mcp_discovery_route_candidate_enablement_reason: toNonEmptyString(routeCandidate?.enablement_reason),
mcp_discovery_route_candidate_next_action: toNonEmptyString(routeCandidate?.recommended_next_action),
mcp_discovery_execution_handoff_v1: executionHandoff,
mcp_discovery_execution_handoff_status: toNonEmptyString(executionHandoff?.handoff_status),
mcp_discovery_execution_handoff_allowed_hot_chain: executionHandoff?.allowed_hot_chain === true,
mcp_discovery_execution_handoff_can_use_guarded_response: executionHandoff?.can_use_guarded_response === true,
mcp_discovery_answer_mode: toNonEmptyString(answerDraft?.answer_mode),
mcp_discovery_business_fact_answer_allowed: bridge?.business_fact_answer_allowed === true,
mcp_discovery_user_facing_response_allowed: bridge?.user_facing_response_allowed === true,

View File

@ -0,0 +1,83 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_EXECUTION_HANDOFF_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryExecutionHandoff = buildAssistantMcpDiscoveryExecutionHandoff;
exports.ASSISTANT_MCP_DISCOVERY_EXECUTION_HANDOFF_SCHEMA_VERSION = "assistant_mcp_discovery_execution_handoff_v1";
const HOT_HANDOFF_CHAIN_ALLOWLIST = ["value_flow"];
function uniqueStrings(values) {
const result = [];
for (const value of values) {
const text = String(value ?? "").trim();
if (text && !result.includes(text)) {
result.push(text);
}
}
return result;
}
function handoffStatusFor(input, allowedHotChain) {
if (input.bridgeStatus === "needs_clarification" || input.routeCandidate.candidate_status === "needs_user_scope") {
return "awaiting_user_scope";
}
if (input.bridgeStatus === "checked_sources_only") {
return "checked_sources_only";
}
if (input.bridgeStatus === "blocked" ||
input.bridgeStatus === "unsupported" ||
input.routeCandidate.candidate_status === "blocked" ||
input.routeCandidate.candidate_status === "needs_route_enablement") {
return "blocked";
}
if (!allowedHotChain) {
return "not_enabled_for_chain";
}
return "ready_for_guarded_response";
}
function buildAssistantMcpDiscoveryExecutionHandoff(input) {
const allowedHotChain = HOT_HANDOFF_CHAIN_ALLOWLIST.includes(input.routeCandidate.selected_chain_id);
const baseStatus = handoffStatusFor(input, allowedHotChain);
const readinessChecksPassed = baseStatus === "ready_for_guarded_response" &&
input.bridgeStatus === "answer_draft_ready" &&
input.routeCandidate.candidate_status === "ready_for_reviewed_execution" &&
input.routeCandidate.executable_now === true &&
input.pilot.pilot_status === "executed" &&
input.pilot.mcp_execution_performed === true &&
input.businessFactAnswerAllowed === true &&
input.userFacingResponseAllowed === true &&
input.answerDraft.internal_mechanics_allowed === false;
const handoffStatus = readinessChecksPassed ? "ready_for_guarded_response" : baseStatus;
const reasonCodes = uniqueStrings([
`execution_handoff_status_${handoffStatus}`,
allowedHotChain ? "execution_handoff_chain_allowlisted" : "execution_handoff_chain_not_allowlisted",
input.routeCandidate.executable_now
? "execution_handoff_route_candidate_executable"
: "execution_handoff_route_candidate_not_executable",
input.pilot.mcp_execution_performed
? "execution_handoff_mcp_execution_performed"
: "execution_handoff_mcp_execution_not_performed",
input.businessFactAnswerAllowed
? "execution_handoff_business_fact_allowed"
: "execution_handoff_business_fact_not_allowed",
input.userFacingResponseAllowed
? "execution_handoff_user_facing_allowed"
: "execution_handoff_user_facing_not_allowed",
readinessChecksPassed
? "execution_handoff_guarded_response_ready"
: "execution_handoff_guarded_response_not_ready"
]);
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_EXECUTION_HANDOFF_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryExecutionHandoff",
handoff_status: handoffStatus,
selected_chain_id: input.routeCandidate.selected_chain_id,
route_candidate_status: input.routeCandidate.candidate_status,
evidence_answer_mode: input.routeCandidate.evidence_answer_mode,
evidence_expected_coverage: input.routeCandidate.evidence_expected_coverage,
pilot_status: input.pilot.pilot_status,
answer_mode: input.answerDraft.answer_mode,
mcp_execution_performed: input.pilot.mcp_execution_performed,
allowed_hot_chain: allowedHotChain,
can_use_guarded_response: readinessChecksPassed,
must_keep_internal_mechanics_hidden: true,
reason_codes: reasonCodes
};
}

View File

@ -77,15 +77,54 @@ function aggregationAxisForPlanner(planner) {
const axis = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_aggregation_axis)?.toLowerCase();
return axis === "month" ? "month" : null;
}
function cleanExplicitEntityCandidate(value) {
let text = value.replace(/\s+/g, " ").trim();
if (!text) {
return null;
}
text = text
.replace(/^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu, "")
.replace(/^(?:по\s+)?(?:контрагент(?:у|ом)?|поставщик(?:у|ом)?|клиент(?:у|ом)?)\s+/iu, "")
.replace(/\s+за\s+\d{4}(?:\s+год)?$/iu, "")
.trim();
return text || null;
}
function scoreExplicitEntityCandidate(raw, cleaned, index) {
let score = 100 - index;
if (raw.trim() === cleaned) {
score += 20;
}
if (/^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu.test(raw)) {
score -= 40;
}
if (/[А-ЯЁA-Z]/u.test(cleaned)) {
score += 8;
}
if (/(?:^|\s)(?:ООО|АО|ПАО|ИП|Группа|Комитет|Департамент)(?:\s|$)/u.test(cleaned)) {
score += 6;
}
score -= Math.max(0, cleaned.length - 60);
return score;
}
function firstEntityCandidate(planner) {
const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? [];
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
if (text) {
return text;
let best = null;
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const raw = toNonEmptyString(candidate);
if (!raw) {
continue;
}
const cleaned = cleanExplicitEntityCandidate(raw);
if (!cleaned) {
continue;
}
const score = scoreExplicitEntityCandidate(raw, cleaned, index);
if (!best || score > best.score) {
best = { text: cleaned, score };
}
}
return null;
return best?.text ?? null;
}
function dateScopeToFilters(dateScope) {
if (!dateScope) {

View File

@ -4,6 +4,7 @@ exports.ASSISTANT_MCP_DISCOVERY_PLANNER_SCHEMA_VERSION = void 0;
exports.planAssistantMcpDiscovery = planAssistantMcpDiscovery;
const assistantMcpDiscoveryPolicy_1 = require("./assistantMcpDiscoveryPolicy");
const assistantMcpCatalogIndex_1 = require("./assistantMcpCatalogIndex");
const assistantEvidencePlanner_1 = require("./assistantEvidencePlanner");
exports.ASSISTANT_MCP_DISCOVERY_PLANNER_SCHEMA_VERSION = "assistant_mcp_discovery_planner_v1";
const CHUNKED_COVERAGE_PROBE_BUDGET = 30;
function toNonEmptyString(value) {
@ -41,6 +42,16 @@ function pushAllUnique(target, values) {
pushUnique(target, value);
}
}
function uniqueStringList(values) {
const result = [];
for (const value of values) {
const text = toNonEmptyString(value);
if (text) {
pushUnique(result, text);
}
}
return result;
}
function pushCatalogChainTemplateAlignmentReasons(target, alignment) {
if (alignment.alignment_status === "selected_matches_top") {
pushReason(target, "planner_catalog_chain_template_alignment_evaluated");
@ -1077,6 +1088,55 @@ function statusFrom(plan, review) {
}
return "ready_for_execution";
}
function providedAxesFromPlanTurnMeaning(plan) {
const result = [];
const meaning = plan.turn_meaning_ref;
if ((meaning?.explicit_entity_candidates?.length ?? 0) > 0) {
pushUnique(result, "counterparty");
pushUnique(result, "business_entity");
}
if (toNonEmptyString(meaning?.explicit_organization_scope)) {
pushUnique(result, "organization");
}
if (toNonEmptyString(meaning?.explicit_date_scope)) {
pushUnique(result, "period");
}
if (toNonEmptyString(meaning?.asked_aggregation_axis)) {
pushUnique(result, "aggregate_axis");
}
if (toNonEmptyString(meaning?.metadata_scope_hint)) {
pushUnique(result, "metadata_scope");
}
return result;
}
function preferredClarificationAxesForRecipe(input) {
if (input.recipe.chainId === "value_flow_ranking" ||
input.recipe.chainId === "value_flow_comparison" ||
input.recipe.chainId === "business_overview") {
return ["organization"];
}
if (input.dataNeedGraph?.business_fact_family === "value_flow" && !hasSubjectCandidates(input.dataNeedGraph)) {
return ["organization"];
}
return [];
}
function missingAxesFromCatalogReview(review, input) {
const result = [];
for (const options of Object.values(review.missing_axes_by_primitive)) {
const candidates = options
.map((option) => uniqueStringList(option).filter((axis) => !input.providedAxes.includes(axis)))
.filter((option) => option.length > 0);
const preferredCandidate = candidates.find((option) => option.some((axis) => input.preferredAxes.includes(axis)));
const candidate = preferredCandidate ?? candidates.sort((left, right) => left.length - right.length)[0];
if (!candidate) {
continue;
}
for (const axis of candidate) {
pushUnique(result, axis);
}
}
return result;
}
function planAssistantMcpDiscovery(input) {
const recipe = recipeFor(input);
const budgetOverride = budgetOverrideFor(input, recipe);
@ -1139,6 +1199,17 @@ function planAssistantMcpDiscovery(input) {
else {
pushReason(reasonCodes, "planner_needs_more_user_or_scope_context");
}
const evidencePlan = (0, assistantEvidencePlanner_1.buildAssistantEvidencePlanner)({
selectedChainId: recipe.chainId,
plannerStatus,
semanticDataNeed,
dataNeedGraph,
discoveryPlan: plan,
additionalMissingAxes: missingAxesFromCatalogReview(adjustedReview, {
providedAxes: providedAxesFromPlanTurnMeaning(plan),
preferredAxes: preferredClarificationAxesForRecipe({ recipe, dataNeedGraph })
})
});
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_PLANNER_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryPlanner",
@ -1152,6 +1223,7 @@ function planAssistantMcpDiscovery(input) {
catalog_chain_template_alignment: catalogChainTemplateAlignment,
proposed_primitives: recipe.primitives,
required_axes: recipe.axes,
evidence_plan: evidencePlan,
discovery_plan: plan,
catalog_review: adjustedReview,
reason_codes: reasonCodes

View File

@ -34,6 +34,45 @@ function requestsFinancialCounterpartyBoundary(turnMeaning, graph) {
return (/(?:банк|сбербанк|финанс|кредит|депозит)/iu.test(text) &&
/(?:клиент|поставщик|выручк|топ|обычн|роль|поток)/iu.test(text));
}
function requestsCompactCashflowAnswer(turnMeaning, graph) {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
turnMeaning?.effective_message,
graph?.source_message,
graph?.question
].join(" "));
if (!text) {
return false;
}
if (/(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(text)) {
return true;
}
return /(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u0434\u0435\u043d\p{L}{0,20}\s+\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\u043b\p{L}*[\s\S]{0,80}\u0443\u0448\u043b\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*)[\s\S]{0,120}(?:\u0434\u0435\u043d\p{L}*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u043e\u043b\u0443\u0447|\u043f\u0440\u0438\u0448\u043b))/iu.test(text);
}
function requestsCashflowPolarityAnswer(turnMeaning, graph) {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
turnMeaning?.effective_message,
graph?.source_message,
graph?.question
].join(" "));
return /(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(text);
}
function requestsDirectAccountingProfitAnswer(turnMeaning, graph) {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
turnMeaning?.effective_message,
graph?.source_message,
graph?.question
].join(" "));
if (!/(?:чист\p{L}{0,8}\s+прибыл|финрезультат|финансов\p{L}{0,12}\s+результат)/iu.test(text)) {
return false;
}
if (/(?:^|\s)(?:а\s+)?это\s+чист\p{L}{0,8}\s+прибыл|это\s+прибыл\p{L}{0,4}\s+или\s+нет/iu.test(text)) {
return false;
}
return /(?:какая|какой|сколько|посчитай|рассчитай|покажи|дай|определить|определи|найди|итог|за\s+\d{4})/iu.test(text);
}
function toStringList(value) {
if (!Array.isArray(value)) {
return [];
@ -215,7 +254,7 @@ function localizeLine(value) {
return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С.";
}
if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) {
return "Нетто денежного потока рассчитано только как входящие подтвержденные строки 1С минус исходящие подтвержденные строки 1С.";
return "Нетто денежного потока рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи в 1С.";
}
if (/^Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows$/i.test(value)) {
return "Помесячная нетто-раскладка сгруппирована только по подтвержденным входящим и исходящим строкам 1С.";
@ -403,9 +442,9 @@ function businessOverviewCoverageLimitLine(overview) {
if (outgoing?.coverage_limited_by_probe_limit === true) {
limited.push("исходящие");
}
const continuation = "Если нужен полный сквозной ответ, безопасный следующий шаг — выбрать конкретный год или квартал для дозапроса: тогда широкий срез можно собрать частями без выдачи непроверенного итога.";
const continuation = "Для полного сквозного итога лучше разбить проверку по годам или кварталам.";
return limited.length > 0
? `Важно: по направлению ${limited.join(" и ")} проверка достигла лимита строк; это расширенный проверенный срез найденных строк, но не гарантия полного бухгалтерского оборота без отдельной полной выгрузки. ${continuation}`
? `Ограничение: широкий денежный срез по направлению ${limited.join(" и ")} не считаю полным бухгалтерским оборотом без отдельной полной выгрузки. ${continuation}`
: null;
}
function joinBusinessReplyLines(lines) {
@ -520,37 +559,15 @@ function businessOverviewInventoryLine(overview) {
}
const amount = moneyText(inventory.total_amount_human_ru);
const rows = Number(inventory.rows_matched);
const quantity = Number(inventory.total_quantity);
if (!amount && !Number.isFinite(rows)) {
return null;
}
const pieces = [
Number.isFinite(rows) ? `${rows} позиций` : null,
amount ? `на ${sentenceAmount(amount) ?? amount}` : null,
Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null
amount ? `на ${sentenceAmount(amount) ?? amount}` : null
].filter((item) => Boolean(item));
return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null;
}
function rowCountText(value) {
const count = Number(value);
return Number.isFinite(count) ? String(count) : null;
}
function sideRowsText(side) {
const rowsWithAmount = rowCountText(side?.rows_with_amount);
const rowsMatched = rowCountText(side?.rows_matched);
if (rowsWithAmount && rowsMatched) {
return `${rowsWithAmount} из ${rowsMatched}`;
}
return rowsWithAmount ?? rowsMatched;
}
function sideDateText(side) {
const first = toNonEmptyString(side?.first_movement_date);
const latest = toNonEmptyString(side?.latest_movement_date);
if (first && latest) {
return first === latest ? `дата ${first}` : `даты ${first}..${latest}`;
}
return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null;
}
function bidirectionalNetLabel(direction) {
if (direction === "net_outgoing") {
return "нетто в сторону контрагента";
@ -586,28 +603,24 @@ function buildCompactBidirectionalValueFlowReply(entryPoint, draft) {
: "по выбранному контуру";
const period = toNonEmptyString(flow.period_scope);
const periodText = period ? ` за период ${period}` : " в проверенном окне";
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
const netLabel = bidirectionalNetLabel(flow.net_direction);
const lines = [
`Коротко: ${subjectLead}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
];
const basis = [];
if (incomingRows) {
basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
if (incomingAmount) {
basis.push("входящие платежи");
}
if (outgoingRows) {
basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
if (outgoingAmount) {
basis.push("исходящие платежи");
}
if (basis.length > 0) {
lines.push(`Основа: ${basis.join("; ")}.`);
lines.push(`Основа: проверенные ${basis.join(" и ")} по этому контрагенту в 1С.`);
}
if (flow.coverage_limited_by_probe_limit === true) {
lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
}
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
lines.push("Метод: нетто рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи; это не полное бухгалтерское сальдо вне проверенного окна.");
const fallbackNextStep = toNonEmptyString(draft.next_step_line);
if (fallbackNextStep) {
lines.push(`Следующий шаг: ${localizeLine(fallbackNextStep)}`);
@ -621,6 +634,18 @@ function compactComparable(value) {
.replace(/\s+/g, " ")
.trim();
}
function businessOverviewOrganizationScopeLabel(value) {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const comparable = compactComparable(text);
if (/^(?:с|без)\s+разбивк/.test(comparable) ||
/\b(?:входящ|исходящ|нетто|топ|контрагент|платеж|поступлен)\b/.test(comparable)) {
return null;
}
return text;
}
function businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope) {
const candidates = uniqueStrings([
...toStringList(turnMeaning?.business_overview_separate_entity_candidates),
@ -673,21 +698,17 @@ function buildPreviousCounterpartyValueFlowSummary(flow, separateSubject, docume
const lead = `; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` +
`${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`;
const basis = [];
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
if (incomingRows) {
basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
if (incomingAmount) {
basis.push("входящие платежи");
}
if (outgoingRows) {
basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
if (outgoingAmount) {
basis.push("исходящие платежи");
}
const documents = previousDocumentSummaryLine(documentBundle, counterparty);
if (documents) {
basis.push(documents);
}
const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : "";
const basisText = basis.length > 0 ? ` Основа: проверенные ${basis.join(" и ")}.` : "";
return {
lead,
line: `Отдельно по контрагенту ${counterparty}: подтверждено получили ${incomingAmount ?? "0 руб."}, ` +
@ -716,7 +737,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто";
const period = businessOverviewPeriodText(overview);
const limitLine = businessOverviewCoverageLimitLine(overview);
const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
const organizationScope = businessOverviewOrganizationScopeLabel(turnMeaning?.explicit_organization_scope);
const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope);
const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary(toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle), separateSubject, toRecordObject(turnMeaning?.previous_counterparty_document_bundle));
const organizationPrefix = organizationScope ? `по компании ${organizationScope} ` : "";
@ -760,6 +781,23 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
const debtDueDateBoundary = actionFamily === "debt_due_date_boundary" || unsupportedFamily === "debt_due_date_boundary";
const vendorRiskBoundary = actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary";
const inventoryReserveBoundary = actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary";
const compactCashflowRequested = directMoneyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph);
const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph);
const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph);
if (compactCashflowRequested && !rankingNeed && (incomingAmount || outgoingAmount || netAmount)) {
const netDisplay = sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431.";
const signedNetDisplay = cashflowPolarityRequested && netDisplay && !String(netDisplay).trim().startsWith("-")
? `+${netDisplay}`
: netDisplay;
const polarityLead = cashflowPolarityRequested
? `\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c \u043f\u043b\u044e\u0441. `
: "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: ";
lines.push(`${polarityLead}${organizationPrefix}${period} \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${signedNetDisplay}.`);
lines.push(cashflowPolarityRequested
? "\u042d\u0442\u043e \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c 1\u0421, \u043d\u0435 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442."
: "\u042d\u0442\u043e \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c 1\u0421, \u043d\u0435 \u0447\u0438\u0441\u0442\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438 \u043d\u0435 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442.");
return joinBusinessReplyLines(lines);
}
if (profitMarginBoundary) {
const accountingFinancialResult = toRecordObject(overview.accounting_financial_result);
if (accountingFinancialResult) {
@ -780,8 +818,12 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
? `минус ${amount}`
: amount
: "сумма не распознана";
lines.push(`Коротко: нет, денежное операционное нетто не стоит считать чистой прибылью. Отдельно по закрытию счетов 90/91/99 в 1С за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к подтвержденной выручке ${marginPct}` : "; маржа к подтвержденной выручке не рассчитана"}.`);
lines.push("Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность.");
lines.push(directAccountingProfitRequested
? `Коротко: за ${periodScope} по закрытию счетов 90/91/99 в 1С подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к подтвержденной выручке ${marginPct}` : "; маржа к подтвержденной выручке не рассчитана"}.`
: `Коротко: нет, денежное операционное нетто не стоит считать чистой прибылью. Отдельно по закрытию счетов 90/91/99 в 1С за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к подтвержденной выручке ${marginPct}` : "; маржа к подтвержденной выручке не рассчитана"}.`);
lines.push(directAccountingProfitRequested
? "Это учетный финрезультат по найденным строкам закрытия периода в 1С, не денежный поток, не внешний аудит и не юридически подтвержденная отчетность."
: "Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность.");
return joinBusinessReplyLines(lines);
}
const headline = toNonEmptyString(draft.headline);
@ -931,6 +973,58 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
lines.push("Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; косвенные признаки нельзя выдавать за доказанный факт резерва.");
return joinBusinessReplyLines(lines);
}
if (!separateSubject && !crossScopeExecutiveSummary && (actionFamily === "broad_evaluation" || unsupportedFamily === "broad_business_evaluation")) {
const subject = organizationScope ?? "компания";
const periodWithoutPrefix = period.replace(/^за\s+/iu, "");
lines.push(`Коротко: по доступным данным ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж.`);
lines.push("Что видно:");
if (incomingAmount) {
lines.push(`- входящие деньги за ${periodWithoutPrefix}: ${incomingAmount};`);
}
if (outgoingAmount) {
lines.push(`- исходящие платежи/списания: ${outgoingAmount};`);
}
if (netAmount) {
lines.push(`- ${netDirection}: ${sentenceAmount(netAmount) ?? netAmount};`);
}
if (customerName && customerAmount) {
lines.push(topCustomerLooksFinancial
? `- крупнейший входящий источник: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}; это похоже на финансовый контур, не на обычную клиентскую выручку;${nonFinancialCustomer ? ` крупнейший небанковский входящий контрагент: ${nonFinancialCustomer};` : ""}`
: `- крупнейший источник денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}; это сигнал концентрации на крупном заказчике;`);
}
if (topSupplier) {
lines.push(topSupplierLooksFinancial
? `- крупнейший получатель исходящих денег: ${topSupplier}; это похоже на финансовый контур, не на обычного поставщика;`
: `- крупнейший получатель исходящих денег: ${topSupplier};`);
}
const inventoryLine = businessOverviewInventoryLine(overview);
if (inventoryLine) {
lines.push(`- ${localizeLine(inventoryLine)}`);
}
const debtLine = businessOverviewDebtLine(overview);
if (debtLine) {
lines.push(`- ${localizeLine(debtLine)}`);
}
lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль.");
const missingOverviewFamilies = [];
if (!businessOverviewTaxLine(overview)) {
missingOverviewFamilies.push("НДС/налоговая позиция без отдельного точного расчета");
}
if (!debtLine) {
missingOverviewFamilies.push("долги без даты среза");
}
if (!inventoryLine) {
missingOverviewFamilies.push("склад без даты среза");
}
if (missingOverviewFamilies.length > 0) {
lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`);
}
if (limitLine) {
lines.push(limitLine);
}
lines.push("Что проверить дальше: чистую прибыль через 90/91/99, маржинальность по проектам, зависимость от топ-3 контрагентов, старые складские остатки и зависшие расчеты.");
return joinBusinessReplyLines(lines);
}
if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) {
lines.push(`Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.`);
lines.push(previousCounterpartySummary.line);
@ -942,6 +1036,29 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
return joinBusinessReplyLines(lines);
}
if (rankingNeed) {
const explicitPeriodRankingOverview = period &&
!/(?:все\s+доступное|все\s+время|all\s+time)/iu.test(period) &&
(incomingAmount || outgoingAmount || netAmount);
if (explicitPeriodRankingOverview) {
lines.push(`Коротко: ${organizationPrefix}${period} денежная картина подтверждена по найденным строкам 1С.`);
lines.push(`Деньги: входящие ${incomingAmount ?? "0 руб."}, исходящие ${outgoingAmount ?? "0 руб."}, расчетное операционное нетто ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`);
if (customerName && customerAmount) {
lines.push(topCustomerLooksFinancial
? `Топ входящих: 1. ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}. Это финансовый/банковский контур, не считаю его клиентской выручкой без назначения платежа.${nonFinancialCustomer ? ` 2. Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
: `Крупнейший входящий контрагент: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`);
}
if (topSupplier) {
lines.push(topSupplierLooksFinancial
? `Топ исходящих: 1. ${topSupplier}. Это финансовый/банковский контур, не считаю его обычным поставщиком без назначения платежа и договора.${nonFinancialSupplier ? ` 2. Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}`
: `Крупнейший получатель исходящих денег: ${topSupplier}.`);
}
lines.push(`Вывод: по движению денег период ${netDirection}; это не чистая прибыль и не бухгалтерский финрезультат.`);
if (requestedFinancialBoundaryLine) {
lines.push(requestedFinancialBoundaryLine);
}
lines.push("Следующий шаг: могу отдельно посчитать чистую прибыль через закрытие 90/91/99 или разложить этот период по контрагентам.");
return joinBusinessReplyLines(lines);
}
const incomingLeader = strongestIncomingYear(overview);
const canRankYearlyNet = !limitLine;
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
@ -1062,6 +1179,14 @@ function statusFrom(entryPoint) {
}
return "not_applicable";
}
function hasGuardedHotHandoff(entryPoint) {
const bridge = toRecordObject(entryPoint?.bridge);
const handoff = toRecordObject(bridge?.execution_handoff);
return (entryPoint?.hot_runtime_wired === true &&
bridge?.hot_runtime_wired === true &&
handoff?.can_use_guarded_response === true &&
handoff?.must_keep_internal_mechanics_hidden === true);
}
function replyTypeFor(status) {
if (status === "clarification_candidate") {
return "clarification_required";
@ -1102,9 +1227,12 @@ function buildReplyText(entryPoint, status) {
function buildAssistantMcpDiscoveryResponseCandidate(entryPoint) {
const entry = entryPoint ?? null;
const status = statusFrom(entry);
const guardedHotHandoff = hasGuardedHotHandoff(entry);
const reasonCodes = uniqueStrings(entry?.reason_codes ?? []);
pushReason(reasonCodes, `mcp_discovery_response_candidate_${status}`);
pushReason(reasonCodes, "mcp_discovery_response_candidate_not_hot_wired");
pushReason(reasonCodes, guardedHotHandoff
? "mcp_discovery_response_candidate_guarded_hot_wired"
: "mcp_discovery_response_candidate_not_hot_wired");
const replyText = entry && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate")
? buildReplyText(entry, status)
: null;
@ -1112,10 +1240,11 @@ function buildAssistantMcpDiscoveryResponseCandidate(entryPoint) {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryResponseCandidate",
candidate_status: replyText ? status : status === "clarification_candidate" ? status : status,
hot_runtime_wired: false,
hot_runtime_wired: guardedHotHandoff,
reply_type: replyTypeFor(status),
reply_text: replyText,
eligible_for_future_hot_runtime: Boolean(replyText) && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"),
eligible_for_future_hot_runtime: Boolean(replyText) &&
(status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"),
must_keep_internal_mechanics_hidden: true,
reason_codes: reasonCodes
};

View File

@ -141,6 +141,7 @@ function isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning) {
if (normalizedIntent === "inventory_purchase_provenance_for_item" ||
normalizedIntent === "inventory_purchase_documents_for_item" ||
normalizedIntent === "inventory_sale_trace_for_item" ||
normalizedIntent === "inventory_margin_ranking_for_nomenclature" ||
normalizedIntent === "inventory_profitability_for_item" ||
normalizedIntent === "inventory_purchase_to_sale_chain") {
return askedDomain === "inventory";
@ -183,7 +184,7 @@ function isExplicitMetadataDiscoveryTurn(entryPoint) {
reasonCodes.some((reason) => toNonEmptyString(reason) === "mcp_discovery_metadata_signal_detected"));
}
function isInventoryExactAddressIntent(intent) {
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(String(intent ?? ""));
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(String(intent ?? ""));
}
function hasMetadataDiscoveryPriority(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
@ -203,12 +204,16 @@ function hasMetadataDiscoveryPriority(input, entryPoint) {
}
function isOpenScopeValueFlowWithoutSubject(entryPoint) {
const graph = readDiscoveryDataNeedGraph(entryPoint);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const businessFactFamily = toNonEmptyString(graph?.business_fact_family);
const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : [];
const reasonCodes = Array.isArray(graph?.reason_codes) ? graph.reason_codes : [];
const reasonCodes = readStringArray(graph?.reason_codes);
const clarificationGaps = readStringArray(graph?.clarification_gaps);
const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
return (businessFactFamily === "value_flow" &&
subjectCandidates.length === 0 &&
reasonCodes.some((reason) => toNonEmptyString(reason) === "data_need_graph_open_scope_total_without_subject"));
(reasonCodes.includes("data_need_graph_open_scope_total_without_subject") ||
(Boolean(explicitOrganizationScope) && clarificationGaps.includes("subject"))));
}
function needsOpenScopeValueFlowOrganizationClarification(entryPoint) {
const graph = readDiscoveryDataNeedGraph(entryPoint);
@ -345,6 +350,24 @@ function hasExactBankOperationsAddressReply(input, entryPoint) {
routeMode === "exact" ||
hasFullConfirmedTruth(input));
}
function hasInventoryMarginRankingAddressReply(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
if (!toNonEmptyString(input.currentReply)) {
return false;
}
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const capabilityId = toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id);
return Boolean(detectedIntent === "inventory_margin_ranking_for_nomenclature" ||
selectedRecipe === "address_inventory_margin_ranking_for_nomenclature_v1" ||
capabilityId === "inventory_inventory_margin_ranking_for_nomenclature");
}
function hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
@ -439,6 +462,9 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) {
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
@ -462,6 +488,9 @@ function hasRuntimeMatchedExactReply(input, entryPoint) {
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
@ -482,6 +511,9 @@ function hasAlignedFactualAddressReply(input, entryPoint) {
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
@ -537,6 +569,9 @@ function hasMatchedFactualAddressContinuationTarget(input, entryPoint) {
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const dialogContinuationContract = toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract) ??
toRecordObject(input.addressRuntimeMeta?.dialog_continuation_contract_v2);
@ -577,6 +612,9 @@ function hasFullConfirmedFactualAddressReply(input, entryPoint) {
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
return hasFullConfirmedTruth(input);
}
function applyAssistantMcpDiscoveryResponsePolicy(input) {
@ -601,6 +639,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
const staleMetadataDiscoveryFallbackAgainstExactAddressReply = hasStaleMetadataDiscoveryFallbackAgainstExactAddressReply(input, entryPoint);
const exactValueFlowReplyForBusinessOverviewDirectMoneyNeed = hasExactValueFlowReplyForBusinessOverviewDirectMoneyNeed(input, entryPoint);
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint);
@ -665,6 +704,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
if (exactBankOperationsAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_bank_operations_address_reply");
}
if (inventoryMarginRankingAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply");
}
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate");
}
@ -674,6 +716,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
if (!candidate.eligible_for_future_hot_runtime) {
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_not_eligible");
}
if (candidate.hot_runtime_wired) {
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_guarded_hot_wired");
}
if (!toNonEmptyString(candidate.reply_text)) {
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_missing_reply_text");
}
@ -692,6 +737,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
!staleMetadataDiscoveryFallbackAgainstExactAddressReply &&
!exactValueFlowReplyForBusinessOverviewDirectMoneyNeed &&
!exactBankOperationsAddressReply &&
!inventoryMarginRankingAddressReply &&
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
candidate.eligible_for_future_hot_runtime &&

View File

@ -4,6 +4,7 @@ exports.ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DIS
exports.runAssistantMcpDiscoveryRuntimeBridge = runAssistantMcpDiscoveryRuntimeBridge;
const assistantMcpDiscoveryAnswerAdapter_1 = require("./assistantMcpDiscoveryAnswerAdapter");
const assistantMcpDiscoveryPilotExecutor_1 = require("./assistantMcpDiscoveryPilotExecutor");
const assistantMcpDiscoveryExecutionHandoff_1 = require("./assistantMcpDiscoveryExecutionHandoff");
const assistantMcpDiscoveryPlanner_1 = require("./assistantMcpDiscoveryPlanner");
exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_bridge_v1";
exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = "assistant_mcp_discovery_loop_state_v1";
@ -185,8 +186,13 @@ function routeCandidateNextAction(status) {
}
function buildRouteCandidate(planner, pilot, bridgeStatus) {
const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? [];
const providedAxes = flattenAxes(pilot, "provided_axes");
const missingAxes = plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options");
const evidenceAxes = planner.evidence_plan.evidence_axes;
const providedAxes = uniqueStrings([...evidenceAxes.provided_axes, ...flattenAxes(pilot, "provided_axes")]);
const missingAxes = plannerClarificationGaps.length > 0
? plannerClarificationGaps
: evidenceAxes.user_actionable_missing_axes.length > 0
? evidenceAxes.user_actionable_missing_axes
: flattenAxes(pilot, "missing_axis_options");
const missingProofFamily = routeCandidateMissingProofFamily(planner, pilot);
const candidateStatus = routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily);
return {
@ -197,9 +203,12 @@ function buildRouteCandidate(planner, pilot, bridgeStatus) {
selected_chain_summary: planner.selected_chain_summary,
nearest_catalog_chain_template: planner.catalog_chain_template_alignment.top_chain_template_match,
catalog_alignment_status: planner.catalog_chain_template_alignment.alignment_status,
business_fact_family: planner.data_need_graph?.business_fact_family ?? null,
action_family: planner.data_need_graph?.action_family ?? null,
proof_expectation: planner.data_need_graph?.proof_expectation ?? null,
business_fact_family: planner.evidence_plan.data_need.business_fact_family,
action_family: planner.evidence_plan.data_need.action_family,
proof_expectation: planner.evidence_plan.data_need.proof_expectation,
evidence_plan_status: planner.evidence_plan.planner_status,
evidence_answer_mode: planner.evidence_plan.answer_contract.answer_mode,
evidence_expected_coverage: planner.evidence_plan.coverage_gate.expected_coverage,
required_axes: [...planner.required_axes],
provided_axes: providedAxes,
missing_axes: missingAxes,
@ -207,7 +216,7 @@ function buildRouteCandidate(planner, pilot, bridgeStatus) {
enablement_reason: routeCandidateEnablementReason(candidateStatus, pilot, missingAxes, missingProofFamily),
recommended_next_action: routeCandidateNextAction(candidateStatus),
forbidden_overclaim_flags: uniqueStrings([
...(planner.data_need_graph?.forbidden_overclaim_flags ?? []),
...planner.evidence_plan.answer_contract.forbidden_overclaim_flags,
...(missingProofFamily ? [missingProofFamily.must_not_claim] : [])
])
};
@ -250,24 +259,39 @@ async function runAssistantMcpDiscoveryRuntimeBridge(input) {
const bridgeStatus = bridgeStatusFor(pilot, answerDraft);
const loopState = buildLoopState(planner, pilot, bridgeStatus);
const routeCandidate = buildRouteCandidate(planner, pilot, bridgeStatus);
const userFacingResponseAllowed = bridgeStatus !== "blocked";
const businessFactAllowed = businessFactAnswerAllowed(answerDraft);
const executionHandoff = (0, assistantMcpDiscoveryExecutionHandoff_1.buildAssistantMcpDiscoveryExecutionHandoff)({
bridgeStatus,
routeCandidate,
pilot,
answerDraft,
businessFactAnswerAllowed: businessFactAllowed,
userFacingResponseAllowed
});
const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]);
pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`);
pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer");
pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`);
pushReason(reasonCodes, "runtime_bridge_route_candidate_built");
pushReason(reasonCodes, `runtime_bridge_route_candidate_${routeCandidate.candidate_status}`);
pushReason(reasonCodes, `runtime_bridge_execution_handoff_${executionHandoff.handoff_status}`);
for (const reasonCode of executionHandoff.reason_codes) {
pushReason(reasonCodes, reasonCode);
}
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
bridge_status: bridgeStatus,
hot_runtime_wired: false,
hot_runtime_wired: executionHandoff.can_use_guarded_response,
planner,
pilot,
answer_draft: answerDraft,
loop_state: loopState,
route_candidate: routeCandidate,
user_facing_response_allowed: bridgeStatus !== "blocked",
business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft),
execution_handoff: executionHandoff,
user_facing_response_allowed: userFacingResponseAllowed,
business_fact_answer_allowed: businessFactAllowed,
requires_user_clarification: bridgeStatus === "needs_clarification",
reason_codes: reasonCodes
};

View File

@ -69,12 +69,14 @@ async function runAssistantMcpDiscoveryRuntimeEntryPoint(input) {
});
const reasonCodes = uniqueStrings([...turnInput.reason_codes, ...bridge.reason_codes]);
pushReason(reasonCodes, "runtime_entry_point_bridge_executed");
pushReason(reasonCodes, "runtime_entry_point_not_wired_to_hot_assistant_answer");
pushReason(reasonCodes, bridge.hot_runtime_wired
? "runtime_entry_point_wired_to_guarded_hot_assistant_answer"
: "runtime_entry_point_not_wired_to_hot_assistant_answer");
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
entry_status: "bridge_executed",
hot_runtime_wired: false,
hot_runtime_wired: bridge.hot_runtime_wired,
discovery_attempted: true,
turn_input: turnInput,
bridge,

View File

@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryTurnInput = buildAssistantMcpDiscoveryTurnInput;
const assistantMcpDiscoveryDataNeedGraph_1 = require("./assistantMcpDiscoveryDataNeedGraph");
const assistantOrganizationMatcher_1 = require("./assistantOrganizationMatcher");
const addressTextRepair_1 = require("./addressTextRepair");
exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = "assistant_mcp_discovery_turn_input_v1";
function toRecordObject(value) {
@ -273,6 +274,17 @@ function collectEntityCandidates(value) {
}
return result;
}
function collectReasonCodes(value) {
const result = [];
if (!Array.isArray(value)) {
const reason = toNonEmptyString(value);
return reason ? [reason] : result;
}
for (const item of value) {
pushUnique(result, item);
}
return result;
}
function collectPredecomposeEntities(predecompose) {
const entities = toRecordObject(predecompose?.entities);
const organization = toNonEmptyString(entities?.organization);
@ -734,11 +746,13 @@ function hasBusinessOverviewContinuationSignal(text) {
const hasFinalSummaryCue = /(?:\u0447\u0442\u043e\s+\u043c\u044b\s+\u0437\u043d\u0430\u0435\u043c|\u0447\u0442\u043e\s+\u043f\u043e\u043d\u044f\u0442\u043d\u043e|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\w*\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0441\u043b\u0435\u0434\u0443\u044e\u0449\w*\s+\u0448\u0430\u0433|\u0438\u0442\u043e\u0433\w*\s+\u0432\u044b\u0432\u043e\u0434|\u043a\u0430\u043a\u043e\u0439\s+\u0432\u044b\u0432\u043e\u0434|\u0447\u0442\u043e\s+\u0441\s+\u044d\u0442\u0438\u043c\s+\u0434\u0435\u043b\u0430\u0442\u044c|what\s+do\s+we\s+know|what\s+is\s+missing|next\s+step|final\s+summary)/iu.test(normalized);
const hasMoneyBreakdownCue = /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*\s+\u0434\u0435\u043d\p{L}*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u0432\u0441\u0435\u0433\u043e\s+\u043f\u043e\u043b\u0443\u0447|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u0432\u0441\u0435\u0433\u043e\s+)?\u0437\u0430\u043f\u043b\u0430\u0442|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\u043d\p{L}*\s+\u043f\u043e\u0442\u043e\u043a|\u0433\u043b\u0430\u0432\u043d\p{L}*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a)|top\s+(?:customer|supplier)|cash\s+breakdown)/iu.test(normalized) &&
/(?:\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043d\u0435\u0442\u0442\u043e|\u0434\u0435\u043d\p{L}*|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|received|paid|net|cash|customer|supplier)/iu.test(normalized);
const hasCashflowPolarityCue = /(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(normalized);
return (hasEvidenceContinuationCue ||
hasAnalystContinuationCue ||
hasTaxContinuationCue ||
hasFinalSummaryCue ||
hasMoneyBreakdownCue);
hasMoneyBreakdownCue ||
hasCashflowPolarityCue);
}
function hasExplicitVatQuestionSignal(text) {
if (!text) {
@ -785,6 +799,9 @@ function hasBusinessOverviewFollowupSeed(followupSeed) {
followupSeed.loopSelectedChainId === "business_overview");
}
function hasValueFlowSignal(text) {
if (/(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\u043a|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b|\u0441\u043f\u0438\u0441\u0430\u043d|\u0440\u0430\u0441\u0445\u043e\u0434|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u043b\u0443\u0447(?:\u0438\u043b|\u0435\u043d\u043e|\u0435\u043d)|\u043f\u043e\u0441\u0442\u0443\u043f\u0438\u043b|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d|\u0434\u0435\u043d\p{L}*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow|\bearn(?:ed|ing|ings)?\b)/iu.test(text)) {
return true;
}
return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|входящ|получ(?:ил|ено|ен)|поступил|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|(?<!\p{L})заработ(?:ал|али|ало|аем|ает|ать|ано|ок)(?!\p{L})|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow|\bearn(?:ed|ing|ings)?\b)/iu.test(text);
}
function hasValueFlowAggregateQuestionSignal(text) {
@ -794,6 +811,9 @@ function hasPayoutSignal(text) {
return /(?:\bмы\s+(?:за)?плат|(?:за)?платил|оплатил|перечисл|списан|расход|поставщик|исходящ|supplier|payout|outflow|paid\s+to|payment\s+to)/iu.test(text);
}
function hasBidirectionalValueFlowSignal(text) {
if (/(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0431\u0430\u043b\u0430\u043d\u0441\s+(?:\u043f\u043b\u0430\u0442|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436)|\u0432\u0437\u0430\u0438\u043c\u043e\u0440\u0430\u0441\u0447[\u0435\u0451]\u0442|\u043f\u043e\u043b\u0443\u0447\p{L}*.*(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*|(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*.*\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\u044f\u0449.*\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449.*\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u0440\u0438\u0448\p{L}*.*\u0443\u0448\p{L}*|\u0443\u0448\p{L}*.*\u043f\u0440\u0438\u0448\p{L}*|\u043f\u043b\u044e\u0441.*\u043c\u0438\u043d\u0443\u0441|\u043c\u0438\u043d\u0443\u0441.*\u043f\u043b\u044e\u0441|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(text)) {
return true;
}
return /(?:нетто|сальдо|баланс\s+(?:плат|денег|денеж)|взаиморасч[её]т|получил[иа]?.*(?:за)?платил|(?:за)?платил[иа]?.*получил|входящ.*исходящ|исходящ.*входящ|дебет.*кредит|кредит.*дебет|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(text);
}
function hasValueRankingSignal(text) {
@ -820,6 +840,39 @@ function extractOrganizationScopeFromRawText(value) {
.replace(/\s+(?:\u043f\u043e\s+\u0434\u0430\u043d\u043d\u044b\u043c\s+1\u0441|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u0437\u0430\s+(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{2}|(?:19|20)\d{2})(?:\s+\u0433(?:\u043e\u0434|\.)?)?|\u043d\u0430\s+(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{2}|(?:19|20)\d{2})|\u0437\u0430\s+\u0432\u0441[\u0435\u0451]\s+(?:\u0434\u043e\u0441\u0442\u0443\u043f\p{L}+\s+)?\u0432\u0440\u0435\u043c\u044f|\u0437\u0430\s+\u0432\u0435\u0441\u044c\s+(?:\u0434\u043e\u0441\u0442\u0443\u043f\p{L}+\s+)?\u043f\u0435\u0440\u0438\u043e\u0434|\u0437\u0430\s+\u0432\u0441\u044e\s+\u0438\u0441\u0442\u043e\u0440\u0438(?:\u044e|\u0438)|\u0434\u0430\u0439\s+\u0431\u0438\u0437\u043d\u0435\u0441-?\u043e\u0431\u0437\u043e\u0440|\u0431\u0438\u0437\u043d\u0435\u0441-?\u043e\u0431\u0437\u043e\u0440|\u043d\u043e\s+\u043d\u0435).*$/iu, "")
.trim());
}
function extractOrganizationScopeFromSemanticText(value) {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const match = text.match(/(?:^|[\s,;:])(?:\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u0438|\u0438\u044f)|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446(?:\u0438\u0438|\u0438\u044f))\s+(.+?)(?=$|[\n,.;:!?]|\s+\u0437\u0430\s+(?:\d{4}|\d{2}\s*\u0433|\d{2}\s+\u0433\u043e\u0434)|\s+\u043d\u0430\s+(?:\d{4}|\d{2}\s*\u0433|\d{2}\s+\u0433\u043e\u0434))/iu);
return toNonEmptyString(match?.[1]);
}
function normalizeLooseOrganizationAlias(value) {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const normalized = text
.replace(/^[«"'“”]+|[»"'“”]+$/gu, "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
const comparable = (0, addressTextRepair_1.normalizeRussianComparableText)(normalized);
const normalizedTokens = normalized.split(/\s+/u).filter(Boolean);
const hasYearOnlyTimeTail = /\b(?:19|20)\d{2}\b/u.test(normalized) &&
normalizedTokens.length <= 4 &&
!/(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e|llc|inc|corp)/iu.test(comparable);
if (hasYearOnlyTimeTail) {
return null;
}
if (/^(?:\u0438|\u0432|\u0432\u043e|\u0437\u0430|\u043d\u0430|\u043f\u043e|\u043a\u0442\u043e|\u0447\u0442\u043e|\u043a\u0430\u043a(?:\u043e\u0439|\u0430\u044f|\u0438\u0435)?|\u0433\u043b\u0430\u0432\u043d\p{L}*)\b/iu.test(comparable)) {
return null;
}
return normalized;
}
function hasMonthlyAggregationSignal(text) {
return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test(text);
}
@ -1030,7 +1083,10 @@ function metadataScopeHintFromRawText(text) {
return null;
}
function hasExplicitDateScopeLiteral(text) {
return /(?:\b(?:19|20)\d{2}\b|\b\d{4}-\d{2}-\d{2}\b|\b\d{4}-\d{2}\b)/iu.test(text);
if (/\b\d{2}\s*(?:\u0433(?:\u043e\u0434(?:\u0430|\u0443)?)?\.?)\b/iu.test(text)) {
return true;
}
return /(?:\b(?:19|20)\d{2}\b|\b\d{4}-\d{2}-\d{2}\b|\b\d{4}-\d{2}\b|\b\d{2}\s*(?:г(?:од(?:а|у)?)?\.?))\b/iu.test(text);
}
function stripNegatedTaxDateScopeClauses(text) {
const dateScopeLiteral = String.raw `\b(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{2}|(?:19|20)\d{2})\b`;
@ -1054,6 +1110,20 @@ function collectDateScopeFromRawText(text) {
if (year?.[1]) {
return year[1];
}
const utf8ShortYear = text.match(/\b(\d{2})\s*(?:\u0433(?:\u043e\u0434(?:\u0430|\u0443)?)?\.?)\b/iu);
if (utf8ShortYear?.[1]) {
const numericYear = Number.parseInt(utf8ShortYear[1], 10);
if (Number.isFinite(numericYear)) {
return String(numericYear <= 30 ? 2000 + numericYear : 1900 + numericYear);
}
}
const shortYear = text.match(/\b(\d{2})\s*(?:г(?:од(?:а|у)?)?\.?)\b/iu);
if (shortYear?.[1]) {
const numericYear = Number.parseInt(shortYear[1], 10);
if (Number.isFinite(numericYear)) {
return String(numericYear <= 30 ? 2000 + numericYear : 1900 + numericYear);
}
}
return null;
}
function currentIsoDate() {
@ -1137,15 +1207,41 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const reasonCodes = [];
const rawUserText = toNonEmptyString(input.userMessage);
const rawEffectiveText = toNonEmptyString(input.effectiveMessage);
const knownOrganizations = Array.isArray(input.knownOrganizations) ? input.knownOrganizations : [];
const repairedUserText = rawUserText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawUserText) : null;
const repairedEffectiveText = rawEffectiveText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawEffectiveText) : null;
const rawUserSignalSourceText = repairedUserText ?? rawUserText ?? "";
const rawSignalSourceText = `${repairedUserText ?? rawUserText ?? ""} ${repairedEffectiveText ?? rawEffectiveText ?? ""}`.trim();
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
const rawUserEntitySourceText = rawUserSignalSourceText || rawEntitySourceText;
const rawUserTextOnly = compactLower(rawUserSignalSourceText);
const rawAssistantEntityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates);
const rawUserPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawUserTextOnly);
const rawUserLifecyclePivotTextSignal = !rawUserPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawUserTextOnly);
const rawUserBidirectionalValueFlowPivotTextSignal = !rawUserPrimaryBusinessOverviewSignal &&
!rawUserLifecyclePivotTextSignal &&
hasBidirectionalValueFlowSignal(rawUserTextOnly);
const rawUserScopedEntityCandidate = rawUserSignalSourceText
? rawScopedEntityCandidateFromText(rawUserEntitySourceText)
: null;
const rawUserCounterpartyBidirectionalOverride = Boolean(rawUserBidirectionalValueFlowPivotTextSignal &&
(rawUserScopedEntityCandidate ||
predecomposeEntities.counterparty ||
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate))));
const rawText = compactLower(rawSignalSourceText);
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? "");
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) && !rawUserCounterpartyBidirectionalOverride;
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText);
const rawLifecyclePivotTextSignal = !rawPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowPivotTextSignal = !rawPrimaryBusinessOverviewSignal &&
!rawLifecyclePivotTextSignal &&
hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowPivotTextSignal = !rawPrimaryBusinessOverviewSignal &&
!rawLifecyclePivotTextSignal &&
(hasValueFlowSignal(rawText) ||
hasValueRankingSignal(rawText) ||
rawBidirectionalValueFlowPivotTextSignal);
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal);
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) &&
hasBusinessOverviewContinuationSignal(rawText) &&
@ -1163,7 +1259,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
hasMetadataSignal(rawText);
const rawEntityResolutionSignal = !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
const rawValueFlowAggregateQuestionSignal = rawValueFlowSignal && hasValueFlowAggregateQuestionSignal(rawText);
const rawValueFlowAggregateQuestionSignal = (rawValueFlowSignal || rawValueFlowPivotTextSignal) && hasValueFlowAggregateQuestionSignal(rawText);
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText);
const dateScopeSignalText = stripNegatedTaxDateScopeClauses(rawText);
@ -1191,6 +1287,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family);
const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis);
const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family);
const assistantTurnMeaningReasonCodes = collectReasonCodes(assistantTurnMeaning?.reason_codes);
const compactCashflowDisplayFollowupSignal = assistantTurnMeaningReasonCodes.includes("compact_cashflow_display_current_turn_signal");
const broadBusinessEvaluationUnsupported = unsupported === "broad_business_evaluation";
const seededBusinessOverviewSignal = broadBusinessEvaluationUnsupported ||
rawDomain === "business_summary" ||
@ -1216,6 +1314,51 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_evaluation";
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(rawAssistantTurnMeaningOrganizationScope)
? null
: rawAssistantTurnMeaningOrganizationScope;
const organizationSelectionFromKnown = (0, assistantOrganizationMatcher_1.resolveOrganizationSelectionFromMessage)(rawUserText ?? rawEffectiveText ?? rawSignalSourceText, knownOrganizations) ??
(0, assistantOrganizationMatcher_1.resolveOrganizationSelectionFromMessage)(rawSignalSourceText, knownOrganizations) ??
null;
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText) ??
extractOrganizationScopeFromRawText(rawEffectiveText) ??
extractOrganizationScopeFromRawText(rawSignalSourceText);
const semanticOrganizationScope = normalizeLooseOrganizationAlias(extractOrganizationScopeFromSemanticText(rawEffectiveText) ??
extractOrganizationScopeFromSemanticText(rawUserText) ??
extractOrganizationScopeFromSemanticText(rawSignalSourceText));
const predecomposeCounterpartyAsOrganizationScope = (rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
semanticOrganizationScope &&
!rawScopedEntityCandidate &&
!predecomposeEntities.organization
? normalizeLooseOrganizationAlias(predecomposeEntities.counterparty)
: null;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText) ||
Boolean(organizationSelectionFromKnown || rawOrganizationScope || semanticOrganizationScope || predecomposeCounterpartyAsOrganizationScope);
const currentTurnFreshOrganizationScope = organizationSelectionFromKnown ??
predecomposeEntities.organization ??
rawOrganizationScope ??
semanticOrganizationScope ??
predecomposeCounterpartyAsOrganizationScope;
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization);
const organizationMirrorsPredecomposeCounterpartyForPivot = Boolean(sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
sameScopedName(predecomposeEntities.counterparty, currentTurnOrganizationScope) ||
predecomposeOrganizationMirrorsCounterparty);
const normalizedPredecomposeCounterpartyForPivot = organizationMirrorsPredecomposeCounterpartyForPivot
? null
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
const rawExplicitCounterpartyPivotCandidate = rawScopedEntityCandidate ??
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate) &&
!sameScopedName(candidate, currentTurnOrganizationScope)) ??
normalizedPredecomposeCounterpartyForPivot ??
null;
const businessOverviewCounterpartyValueFlowPivot = Boolean(businessOverviewContinuationSignal &&
!rawPrimaryBusinessOverviewSignal &&
rawValueFlowPivotTextSignal &&
rawExplicitCounterpartyPivotCandidate &&
(rawTopicSwitchSignal || rawValueFlowAggregateQuestionSignal));
const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal
? "inventory_reserve_liquidation_boundary"
: debtDueDateBusinessOverviewSignal
@ -1225,8 +1368,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_business_evaluation";
const businessOverviewSignal = rawBusinessOverviewSignal ||
seededBusinessOverviewSignal;
const businessOverviewSignal = !businessOverviewCounterpartyValueFlowPivot &&
(rawBusinessOverviewSignal || seededBusinessOverviewSignal);
const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText));
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
@ -1244,15 +1387,6 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
hasSimpleMovementLanePivotSignal(rawText) ||
hasMovementEvidenceFollowupSignal(rawText) ||
hasPronounMovementEvidenceFollowupSignal(rawText);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(rawAssistantTurnMeaningOrganizationScope)
? null
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const followupCounterpartyIsMetadataOrganizationScope = Boolean(followupSeed.subjectResolutionOptional &&
followupSeed.counterparty &&
(followupSeed.metadataScopeHint ||
@ -1288,7 +1422,6 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawOpenScopeValueFlowOrganizationSignal = Boolean(rawValueFlowSignal &&
!rawBidirectionalValueFlowSignal &&
explicitOrganizationScopeSignal);
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization);
const organizationMirrorsPredecomposeCounterparty = Boolean((rawBidirectionalValueFlowSignal ||
hasValueRankingSignal(rawText) ||
rawOpenScopeValueFlowOrganizationSignal ||
@ -1564,14 +1697,25 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const lifecycleSignal = !businessOverviewSignal && (rawLifecycleSignal || seededDomain === "counterparty_lifecycle");
const bidirectionalValueFlowSignal = !businessOverviewSignal &&
!lifecycleSignal &&
(rawBidirectionalValueFlowSignal || seededAction === "net_value_flow");
((businessOverviewCounterpartyValueFlowPivot
? rawBidirectionalValueFlowPivotTextSignal
: rawBidirectionalValueFlowSignal) ||
seededAction === "net_value_flow");
const valueFlowSignal = !businessOverviewSignal &&
!lifecycleSignal &&
!metadataGroundedMovementLaneApplicable &&
(rawValueFlowSignal || seededDomain === "counterparty_value");
((businessOverviewCounterpartyValueFlowPivot
? rawValueFlowPivotTextSignal
: rawValueFlowSignal) ||
seededDomain === "counterparty_value");
const payoutSignal = valueFlowSignal &&
!bidirectionalValueFlowSignal &&
(rawPayoutSignal || seededAction === "payout");
((businessOverviewCounterpartyValueFlowPivot
? rawValueFlowPivotTextSignal &&
!rawBidirectionalValueFlowPivotTextSignal &&
hasPayoutSignal(rawText)
: rawPayoutSignal) ||
seededAction === "payout");
const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable
? "metadata lane clarification"
: semanticNeedFor({
@ -1579,17 +1723,37 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? "movements"
: businessOverviewSignal
? "business_overview"
: rawDomain ?? seededDomain,
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? "counterparty_value"
: rawDomain ?? seededDomain,
action: explicitVatMovementEvidenceSignal
? "list_movements"
: businessOverviewSignal
? businessOverviewActionFamily
: rawAction ?? seededAction,
: lifecycleSignal
? "activity_duration"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "net_value_flow"
: payoutSignal
? "payout"
: rawAction ?? seededAction ?? "turnover"
: rawAction ?? seededAction,
unsupported: explicitVatMovementEvidenceSignal
? "movement_evidence"
: businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ?? seededUnsupported,
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: unsupported ?? seededUnsupported,
lifecycleSignal,
valueFlowSignal,
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
@ -1733,10 +1897,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
}
}
const clarificationLoopStillNeedsPeriod = Boolean(followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period"));
const rawTwoDigitPredecomposeYearHint = Boolean(predecomposeDateScope &&
/^\d{4}$/.test(predecomposeDateScope) &&
/\b\d{2}\b/u.test(rawText));
const businessOverviewRawWithoutDateScope = Boolean(businessOverviewSignal &&
!rawAllTimeScopeSignal &&
!explicitDateScopeLiteralDetected &&
!rawDateScope &&
!rawTwoDigitPredecomposeYearHint &&
!relativeCurrentDateHintDetected);
const predecomposeDateScopeCountsAsCurrentTurnPeriod = Boolean(predecomposeDateScope &&
!isImplicitCurrentDateScope(predecomposeDateScope) &&
@ -1754,6 +1922,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))));
const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal);
const topicSwitchSuppressesFollowupScope = Boolean(rawTopicSwitchSignal &&
!compactCashflowDisplayFollowupSignal &&
(rawMetadataSignal ||
businessOverviewSignal ||
rawEntitySearchOverridesStaleScope ||
@ -1853,16 +2022,16 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined,
unsupported_but_understood_family: businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ??
(lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: metadataGroundedMovementLaneApplicable
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: unsupported ??
(metadataGroundedMovementLaneApplicable
? "movement_evidence"
: metadataGroundedDocumentLaneApplicable
? "document_evidence"
@ -2137,6 +2306,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (businessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_route_candidate");
}
if (businessOverviewCounterpartyValueFlowPivot) {
pushReason(reasonCodes, "mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow");
}
if (businessOverviewContinuationSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
}
@ -2161,6 +2333,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (businessOverviewRawYearOverridesPredecomposeAsOf) {
pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
}
if (organizationSelectionFromKnown) {
pushReason(reasonCodes, "mcp_discovery_organization_scope_from_known_organizations");
}
if (semanticOrganizationScope) {
pushReason(reasonCodes, "mcp_discovery_organization_scope_from_semantic_text");
}
if (predecomposeCounterpartyAsOrganizationScope) {
pushReason(reasonCodes, "mcp_discovery_counterparty_reinterpreted_as_organization_scope");
}
if (!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
normalizedPredecomposeCounterparty) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose");

View File

@ -27,6 +27,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@ -40,6 +41,7 @@ const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain"
]);
@ -204,7 +206,7 @@ function createAssistantRoutePolicy(deps) {
: null;
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
semanticCanonicalRecommended;
const llmCanonicalEntitySignal = /(?:заказчик|поставщик|контрагент|компан|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
@ -313,6 +315,21 @@ function createAssistantRoutePolicy(deps) {
const hasTemporalCue = /(?:на\s+эту\s+же\s+дат[ауеы]|на\s+тот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return hasRequestCue && hasTemporalCue;
}
function hasInventoryMarginRankingContinuationSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
const wantsFoundRows = /(?:покажи|показать|выведи|дай|раскрой|show|list)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур)/iu.test(normalized);
const account41Not01 = /\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead)/iu.test(normalized);
const periodExpansion = /(?:расширь|расширить|возьми|давай|покажи|за|на|до|весь|год|квартал|месяц|expand|period)/iu.test(normalized) &&
/(?:январ|феврал|март|апрел|ма[йяе]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return wantsFoundRows || account41Not01 || periodExpansion;
}
function hasOrganizationClarificationTextCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
@ -479,6 +496,7 @@ function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const followupPreviousIntent = toNonEmptyString(followupContext?.previous_intent);
const followupRootIntent = toNonEmptyString(followupContext?.root_intent);
const followupPreviousFilters = followupContext?.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: null;
@ -513,6 +531,16 @@ function createAssistantRoutePolicy(deps) {
hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) ||
hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage)));
const protectedInventoryMarginRankingFollowup = Boolean(followupContext &&
(followupPreviousIntent === "inventory_margin_ranking_for_nomenclature" ||
followupRootIntent === "inventory_margin_ranking_for_nomenclature") &&
!dataScopeMetaQuery &&
!capabilityMetaQuery &&
!dangerOrCoercionSignal &&
(hasInventoryMarginRankingContinuationSignal(rawUserMessage) ||
hasInventoryMarginRankingContinuationSignal(repairedRawUserMessage) ||
hasInventoryMarginRankingContinuationSignal(effectiveAddressUserMessage) ||
hasInventoryMarginRankingContinuationSignal(repairedEffectiveAddressUserMessage)));
const organizationClarificationContinuationDetected = Boolean((followupContext || continuitySnapshot.hasGroundedAddressContext) &&
lastOrganizationClarificationDebug &&
explicitOrganizationClarificationSelection &&
@ -569,6 +597,7 @@ function createAssistantRoutePolicy(deps) {
!baseToolGatePreservesAddressLane &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!protectedInventoryShortFollowup &&
!protectedInventoryMarginRankingFollowup &&
!organizationClarificationContinuationDetected &&
!routeCandidateOrganizationClarificationDetected);
const lastAddressAssistantDebug = sessionItems
@ -1078,6 +1107,7 @@ function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage) ||
protectedInventoryMarginRankingFollowup ||
inventoryRootRestatementFollowupDetected);
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
@ -1114,7 +1144,9 @@ function createAssistantRoutePolicy(deps) {
resolvedIntentResolution.intent === "unknown" &&
(!llmContractIntent || llmContractIntent === "unknown"));
const exactAddressIntentProtectedFromSemanticDeepHint = laneProtectionArbitration.exactAddressIntentProtectedFromSemanticDeepHint;
const protectAddressLaneFromFallback = Boolean(laneProtectionArbitration.protectAddressLaneFromFallback || customerValueRankingAddressSignal);
const protectAddressLaneFromFallback = Boolean(laneProtectionArbitration.protectAddressLaneFromFallback ||
customerValueRankingAddressSignal ||
protectedInventoryMarginRankingFollowup);
const vatExplainFollowupSignal = Boolean(followupContext &&
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
/(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`)));
@ -1194,7 +1226,7 @@ function createAssistantRoutePolicy(deps) {
let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane");
let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0");
const semanticAddressLaneRecovery = Boolean(!runAddressLane &&
supportedAddressRouteCandidateDetected &&
(supportedAddressRouteCandidateDetected || protectedInventoryMarginRankingFollowup) &&
!deepAnalysisPreferenceDetected &&
!unsupportedAddressIntentFallbackToDeep &&
!deepAnalysisSignalFallbackToDeep &&
@ -1203,9 +1235,11 @@ function createAssistantRoutePolicy(deps) {
if (semanticAddressLaneRecovery) {
runAddressLane = true;
toolGateDecision = "run_address_lane";
toolGateReason = resolvedIntentResolution.intent !== "unknown" || llmContractIntent
? "address_intent_resolver_detected"
: "address_signal_detected";
toolGateReason = protectedInventoryMarginRankingFollowup
? "followup_context_detected"
: resolvedIntentResolution.intent !== "unknown" || llmContractIntent
? "address_intent_resolver_detected"
: "address_signal_detected";
}
if (unsupportedAddressIntentFallbackToDeep) {
runAddressLane = false;

View File

@ -310,6 +310,18 @@ exports.INVENTORY_CAPABILITY_CONTRACTS = [
answerObjectShape: "inventory_profitability_bundle",
bundleReusePolicy: "sale_trace_bundle_preferred"
}),
inventoryExactCapability({
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
intent_ids: ["inventory_margin_ranking_for_nomenclature"],
entry_modes: ["root_entry", "root_followup", "clarification_resume"],
transitions: ["T1", "T2", "T7"],
requiresFocusObject: false,
requiredAnchors: ["period"],
resultShape: "nomenclature_margin_ranking",
answerObjectShape: "inventory_margin_ranking",
bundleReusePolicy: "none",
scenarioFamilies: ["canonical", "colloquial", "account_41_correction"]
}),
inventoryExactCapability({
capability_id: "inventory_inventory_purchase_to_sale_chain",
intent_ids: ["inventory_purchase_to_sale_chain"],

View File

@ -7,6 +7,26 @@ function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage = null, sourceIntentHint = null) {
if (sourceIntentHint !== "inventory_margin_ranking_for_nomenclature") {
return false;
}
return [userMessage, alternateMessage]
.filter((value) => deps.toNonEmptyString(value))
.map((value) => deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"))
.some((normalized) => {
if (!normalized) {
return false;
}
const wantsFoundRows = /(?:покажи|показать|выведи|дай|раскрой|show|list|покажи|показать|выведи|дай|раскрой)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себестоимостн|реализац|марж|прибыл|номенклат)/iu.test(normalized);
const account41Not01 = /\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вместо|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
});
}
function hasSamePeriodReferenceCue(...values) {
return values
.map((value) => normalizeFollowupText(value))
@ -469,8 +489,24 @@ function createAssistantTransitionPolicy(deps) {
llmPreDecomposeMeta
})
: null;
const hasCompactCashflowFollowupCue = (value) => {
const normalized = normalizeFollowupText(value);
if (!normalized) {
return false;
}
const hasCompactCue = /(?:\u043a\u043e\u0440\u043e\u0442\p{L}*|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u0431\u0435\u0437\s+\u0442\u043e\u043f\p{L}*)/iu.test(normalized);
const hasCashflowCue = /(?:\u0434\u0435\u043d\p{L}*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043d\u0435\u0442\u0442\u043e)/iu.test(normalized);
return hasCompactCue && hasCashflowCue;
};
const compactCashflowFollowupSignal = hasShortValueFlowRetargetCue(userMessage) ||
hasCompactCashflowFollowupCue(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) ||
hasCompactCashflowFollowupCue(String(alternateMessage ?? ""))
: false);
if (assistantTurnMeaning?.stale_replay_forbidden === true &&
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage)) {
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage) &&
!compactCashflowFollowupSignal) {
return null;
}
const latestAddressItem = deps.findLastAddressAssistantItem(items);
@ -535,7 +571,8 @@ function createAssistantTransitionPolicy(deps) {
const hasValueFlowCarryoverSourceHint = sourceIntentHint === "customer_revenue_and_payments" ||
sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1";
sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue);
const navigationFocusObjectHint = navigationSessionState.focusObject;
@ -545,6 +582,7 @@ function createAssistantTransitionPolicy(deps) {
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint)));
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage, sourceIntentHint);
let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage);
let inventoryShortFollowupAlternate = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
@ -558,9 +596,11 @@ function createAssistantTransitionPolicy(deps) {
? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const shortValueFlowRetargetPrimary = hasValueFlowCarryoverSourceHint && hasShortValueFlowRetargetCue(userMessage);
const shortValueFlowRetargetPrimary = hasValueFlowCarryoverSourceHint &&
(hasShortValueFlowRetargetCue(userMessage) || hasCompactCashflowFollowupCue(userMessage));
const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) ||
hasCompactCashflowFollowupCue(String(alternateMessage ?? ""))
: false;
const businessOverviewBoundaryFollowupPrimary = hasBusinessOverviewCarryoverSourceHint && hasBusinessOverviewBoundaryFollowupCue(userMessage);
const businessOverviewBoundaryFollowupAlternate = hasBusinessOverviewCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
@ -573,6 +613,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
@ -582,6 +623,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
@ -622,6 +664,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -645,6 +688,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -757,6 +801,7 @@ function createAssistantTransitionPolicy(deps) {
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!inventoryMarginRankingFollowup &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
@ -816,6 +861,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
@ -827,6 +873,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
@ -849,6 +896,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)

View File

@ -24,6 +24,7 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@ -133,6 +134,72 @@ function detectCounterpartyTurnoverFamily(text) {
entity
};
}
function hasCompactOrganizationCashflowDisplaySignal(text) {
const normalized = String(text ?? "");
if (!normalized) {
return false;
}
const hasCompactCue = /(?:\u043a\u043e\u0440\u043e\u0442\u043a\w*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433\p{L}*|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}*|\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u0431\u0435\u0437\s+\u0441\u043f\u0438\u0441(?:\u043a\p{L}*)?|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\p{L}*)/iu.test(normalized);
if (!hasCompactCue) {
return false;
}
const hasMoneyCue = /(?:\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\u0435\u0436\p{L}*|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0441\u043f\u0438\u0441\u0430\p{L}*|\u043d\u0435\u0442\u0442\u043e|cash|money|incoming|outgoing|net)/iu.test(normalized);
const hasOrganizationEarningsCue = /(?:\u0434\u0430\u0439|\u043f\u043e\u043a\u0430\u0436\p{L}*|\u0438\u0442\u043e\u0433\p{L}*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442\p{L}*|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0434\u0435\u043d\u0435\u0436\p{L}*\s+\u043d\u0435\u0442\u0442\u043e|how\s+much|show|give|earned|received|paid)/iu.test(normalized);
const hasExplicitExclusionCue = /(?:\u0438\u0441\u043a\u043b\u044e\u0447\p{L}*|\u0443\u0431\u0435\u0440\p{L}*|\u043a\u0440\u043e\u043c\u0435|exclude|excluding|without)[\s\S]{0,80}(?:\u043a\u0440\u0443\u043f\u043d\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\p{L}*|\u043a\u043b\u0438\u0435\u043d\u0442\p{L}*|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\p{L}*|\u043e\u043f\u0435\u0440\u0430\u0446\p{L}*|counterpart|customer|supplier|operation)/iu.test(normalized);
const hasSpecificCounterpartyScope = detectScopedCounterpartyEntity(normalized) !== null;
return hasMoneyCue && hasOrganizationEarningsCue && !hasExplicitExclusionCue && !hasSpecificCounterpartyScope;
}
function detectScopedCounterpartyEntity(text) {
const patterns = [
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+(.+?)(?=$|[,.;:!?]|\s+(?:\u0437\u0430|\u043d\u0430|\u0432|\u0432\u043e|\u043a|\u043f\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a|\u043a\u0430\u043a|\u043a\u0430\u043a\u043e\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436\p{L}*|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0432\u0445\u043e\u0434\p{L}*)(?=$|[\s,.;:!?]))/iu,
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+([\p{L}\d._-]{2,})(?=$|[\s,.;:!?])/iu
];
const ignored = new Set([
"\u0433\u043e\u0434",
"\u0433\u043e\u0434\u0430",
"\u043f\u0435\u0440\u0438\u043e\u0434",
"\u043f\u0435\u0440\u0438\u043e\u0434\u0430",
"\u043c\u0435\u0441\u044f\u0446",
"\u043c\u0435\u0441\u044f\u0446\u0430",
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b",
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430",
"\u0434\u0435\u043d\u044c\u0433\u0438",
"\u043d\u0435\u0442\u0442\u043e",
"\u0441\u0430\u043b\u044c\u0434\u043e",
"year",
"period",
"month",
"quarter",
"net"
]);
for (const pattern of patterns) {
const rawEntity = text.match(pattern)?.[1]?.trim() ?? "";
if (!rawEntity) {
continue;
}
const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim();
if (entity.length >= 2 && !ignored.has(entity)) {
return entity;
}
}
return null;
}
function detectCounterpartyBidirectionalValueFlowFamily(text) {
const hasNetCue = /(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|net\s+(?:flow|cash|payment)|cash\s+net)/iu.test(text);
const hasIncomingCue = /(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text);
const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text);
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) {
return null;
}
const entity = detectScopedCounterpartyEntity(text);
if (!entity) {
return null;
}
return {
family: "counterparty_bidirectional_value_flow_or_netting",
entity
};
}
function hasExplicitCounterpartyValueObject(text) {
return /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u0441\u0434\u0435\u043b\u043a|customer|client|counterparty|supplier|vendor|contract|item|product|deal)/iu.test(text);
}
@ -279,14 +346,14 @@ function detectBroadBusinessEvaluation(text) {
}
return null;
}
function buildEntityCandidates(counterpartyTurnover) {
if (!counterpartyTurnover?.entity) {
function buildEntityCandidates(entityFamily) {
if (!entityFamily?.entity) {
return [];
}
return [
{
type: "counterparty",
value: counterpartyTurnover.entity,
value: entityFamily.entity,
source: "current_turn_loose_entity_tail"
}
];
@ -298,29 +365,43 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
const rawText = normalizeTurnText(rawMessage, deps);
const effectiveText = normalizeTurnText(effectiveMessage, deps);
const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`);
const supportedIntent = detectSupportedIntent(joinedText, deps);
const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText);
const compactOrganizationCashflowDisplay = hasCompactOrganizationCashflowDisplaySignal(rawText);
const supportedIntent = compactOrganizationCashflowDisplay ? null : detectSupportedIntent(joinedText, deps);
const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText);
const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText);
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
const broadBusinessEvaluation = selectedObjectInventoryExact ? null : detectBroadBusinessEvaluation(joinedText);
const broadBusinessEvaluation = compactOrganizationCashflowDisplay
? { family: "broad_business_evaluation" }
: selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family
? null
: detectBroadBusinessEvaluation(joinedText);
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
const explicitIntentCandidate = broadBusinessEvaluation?.family
? null
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
const unsupportedFamily = broadBusinessEvaluation?.family
? broadBusinessEvaluation.family
: !explicitIntentCandidate && counterpartyTurnover?.family
? counterpartyTurnover.family
: null;
: !explicitIntentCandidate && counterpartyBidirectionalValueFlow?.family
? counterpartyBidirectionalValueFlow.family
: !explicitIntentCandidate && counterpartyTurnover?.family
? counterpartyTurnover.family
: null;
const reasonCodes = [];
if (supportedIntent?.reason) {
reasonCodes.push(supportedIntent.reason);
}
if (counterpartyBidirectionalValueFlow?.family) {
reasonCodes.push("counterparty_bidirectional_value_flow_current_turn_signal");
}
if (counterpartyTurnover?.family) {
reasonCodes.push("counterparty_turnover_current_turn_signal");
}
if (broadBusinessEvaluation?.family) {
reasonCodes.push("broad_business_evaluation_current_turn_signal");
}
if (compactOrganizationCashflowDisplay) {
reasonCodes.push("compact_cashflow_display_current_turn_signal");
}
if (rawText !== normalizeTurnText(rawMessage, { ...deps, repairAddressMojibake: (value) => String(value ?? "") })) {
reasonCodes.push("mojibake_repair_applied");
}
@ -338,32 +419,39 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
? "inventory"
: broadBusinessEvaluation?.family
? "business_summary"
: explicitIntentCandidate?.includes("counterparty")
? "counterparty"
: counterpartyTurnover?.family
: counterpartyBidirectionalValueFlow?.family
? "counterparty_value"
: explicitIntentCandidate?.includes("counterparty")
? "counterparty"
: null;
: counterpartyTurnover?.family
? "counterparty"
: null;
const askedActionFamily = explicitIntentCandidate === "receivables_confirmed_as_of_date" ||
explicitIntentCandidate === "payables_confirmed_as_of_date" ||
explicitIntentCandidate === "inventory_on_hand_as_of_date"
? "confirmed_snapshot"
: broadBusinessEvaluation?.family
? "broad_evaluation"
: explicitIntentCandidate === "customer_revenue_and_payments" ||
explicitIntentCandidate === "supplier_payouts_profile"
? "counterparty_value_or_turnover"
: explicitIntentCandidate === "vat_liability_confirmed_for_tax_period"
? "confirmed_tax_period"
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
? "confirmed_snapshot"
: explicitIntentCandidate === "vat_payable_forecast"
? "forecast"
: explicitIntentCandidate === "list_documents_by_counterparty"
? "list_documents"
: counterpartyTurnover?.family
? "counterparty_value_or_turnover"
: null;
const staleReplayForbidden = Boolean(unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate));
: counterpartyBidirectionalValueFlow?.family
? "net_value_flow"
: explicitIntentCandidate === "customer_revenue_and_payments" ||
explicitIntentCandidate === "supplier_payouts_profile"
? "counterparty_value_or_turnover"
: explicitIntentCandidate === "vat_liability_confirmed_for_tax_period"
? "confirmed_tax_period"
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
? "confirmed_snapshot"
: explicitIntentCandidate === "vat_payable_forecast"
? "forecast"
: explicitIntentCandidate === "list_documents_by_counterparty"
? "list_documents"
: counterpartyTurnover?.family
? "counterparty_value_or_turnover"
: null;
const staleReplayForbidden = Boolean(unsupportedFamily ||
broadBusinessEvaluation?.family ||
(counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) ||
(counterpartyTurnover?.entity && !explicitIntentCandidate));
return {
schema_version: "assistant_turn_meaning_v1",
raw_message: rawMessage,
@ -373,10 +461,13 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
asked_domain_family: askedDomainFamily,
asked_action_family: askedActionFamily,
explicit_intent_candidate: explicitIntentCandidate,
explicit_entity_candidates: broadBusinessEvaluation?.family ? [] : buildEntityCandidates(counterpartyTurnover),
explicit_entity_candidates: broadBusinessEvaluation?.family
? []
: buildEntityCandidates(counterpartyBidirectionalValueFlow ?? counterpartyTurnover),
meaning_confidence: broadBusinessEvaluation?.family
? "medium"
: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"),
: supportedIntent?.confidence ??
(counterpartyBidirectionalValueFlow?.family || counterpartyTurnover?.family ? "medium" : "low"),
intent_override_strength: explicitIntentCandidate
? "explicit_current_turn_intent"
: staleReplayForbidden

View File

@ -198,21 +198,23 @@ function loadCapabilitiesRegistry() {
}
}
function buildCapabilityContractReplyFromRegistry() {
const registry = loadCapabilitiesRegistry();
const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6);
const groupLines = topGroups.map((group, index) => {
const examples = group.typical_queries
.slice(0, 2)
.map((query) => query.trim())
.filter((query) => query.length > 0)
.join("; ");
return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`;
});
return [
"Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.",
"По основным группам:",
...groupLines,
"Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.",
"Могу быстро смотреть управленческие вещи по данным 1С в режиме чтения.",
"",
"- кто должен денег и кому должны;",
"- какой год или месяц был самым денежным;",
"- какие контрагенты дают основной поток;",
"- что лежит на складе и какие остатки стареют;",
"- сколько НДС к уплате за период;",
"- какие документы, оплаты и договоры есть по контрагенту.",
"",
"Примеры запросов:",
"- кто самый доходный клиент за все время",
"- что зависло на складе",
"- кому мы должны на сегодня",
"- какое нетто по СВК за 2020",
"- сколько НДС к уплате за 4 квартал 2019",
"",
"Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере."
].join("\n");
}

View File

@ -31,6 +31,8 @@ function toRouteHintSummaryV1(normalized) {
}
const ACCOUNT_HINT_PATTERN = /(?:\b(?:account|acct|schet|счет|сч)\s*[:#]?\s*(?:[1-9][0-9](?:[./-][0-9]{1,2})?)|\b(?:19|20|21|23|25|26|28|29|44|51|60|62|68)\b)/i;
const PERIOD_PATTERN = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/i;
const BIDIRECTIONAL_VALUE_FLOW_PATTERN = /(?:\b(?:receive(?:d)?|received|get|got|incoming|inflow|paid|payment|payments|outgoing|outflow|net|netto|cash\s*flow)\b|получ(?:ить|ил[аи]?|ено|аем|или)|поступ(?:ил[аи]?|ление|ления)|заплат(?:ить|ил[аи]?|или)|оплат(?:ить|ил[аи]?|ы|или)|входящ(?:ий|ие|их)|исходящ(?:ий|ие|их)|нетто|сальдо)/iu;
const COUNTERPARTY_SCOPE_PATTERN = /(?:\b(?:counterparty|supplier|customer|vendor|client|bank)\b|контрагент|поставщик|покупател|клиент|заказчик|банк|сбербанк|по\s+(?:ип|ооо|пао|зао|оао|группа)\b)/iu;
const SYMPTOM_MARKER_PATTERN = /(?:\bsymptom\b|\banomaly\b|\bproblem\b|\bissue\b|\btail\b|\bhanging\b|\bblocked\b|\bincomplete\b|remains?\s+open|not\s+(?:confirmed|observed|resolved|closed)|не\s+(?:подтвержден|закрыт|наблюдается)|хвост|сбой|проблем)/i;
const LIFECYCLE_MARKER_PATTERN = /(?:\blifecycle\b|\bchain\b|\btransition\b|\bstep\b|\btrace\b|цепоч|этап|переход|связк|где\s+разрыв)/i;
const CHAIN_BREAK_PATTERN = /(?:\bbreak\b|\bbroken\b|\bgap\b|missing\s+(?:transition|step|link)|chain\s+break|разрыв|обрыв|нет\s+переход|не\s+дошл|не\s+наблюд)/i;
@ -54,6 +56,13 @@ exports.ROUTE_DISCIPLINE_RULE_TABLE = [
forbidden_fallback: ["store_canonical", "hybrid_store_plus_live"],
description: "Ranking and period summary queries require analytical batch path."
},
{
query_class: "bidirectional_value_flow",
required_route: "hybrid_store_plus_live",
allowed_fallback: ["no_route"],
forbidden_fallback: ["store_canonical"],
description: "Scoped bidirectional value-flow questions require hybrid evidence path."
},
{
query_class: "symptom_first",
required_route: "hybrid_store_plus_live",
@ -155,6 +164,17 @@ function hasAmbiguitySignal(fragment, lowerText) {
function hasAccountOrPeriodAnchor(fragment, lowerText) {
return fragment.account_hints.length > 0 || ACCOUNT_HINT_PATTERN.test(lowerText) || PERIOD_PATTERN.test(lowerText);
}
function hasBidirectionalValueFlowSignal(fragment, lowerText) {
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
return false;
}
return BIDIRECTIONAL_VALUE_FLOW_PATTERN.test(lowerText);
}
function hasCounterpartyScopeSignal(fragment, lowerText) {
return (COUNTERPARTY_SCOPE_PATTERN.test(lowerText) ||
fragment.entity_hints.some((hint) => hint.trim().length > 0) ||
fragment.candidate_labels.includes("cross_entity"));
}
function resolveRouteClass(fragment) {
const lowerText = mergedFragmentText(fragment);
const symptomSignal = hasSymptomSignal(fragment, lowerText);
@ -164,12 +184,17 @@ function resolveRouteClass(fragment) {
const causalSignal = hasCausalSignal(lowerText);
const ambiguitySignal = hasAmbiguitySignal(fragment, lowerText);
const accountOrPeriodAnchor = hasAccountOrPeriodAnchor(fragment, lowerText);
const bidirectionalValueFlowSignal = hasBidirectionalValueFlowSignal(fragment, lowerText);
const counterpartyScopeSignal = hasCounterpartyScopeSignal(fragment, lowerText);
if (fragment.flags.asks_for_exact_object_trace) {
return ROUTE_DISCIPLINE_RULE_MAP.get("exact_object_trace");
}
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
return ROUTE_DISCIPLINE_RULE_MAP.get("ranking_or_period_summary");
}
if (bidirectionalValueFlowSignal && counterpartyScopeSignal) {
return ROUTE_DISCIPLINE_RULE_MAP.get("bidirectional_value_flow");
}
if (ambiguitySignal && (symptomSignal || lifecycleSignal || chainBreakSignal || periodImpactSignal || causalSignal)) {
return ROUTE_DISCIPLINE_RULE_MAP.get("mixed_ambiguity");
}
@ -205,7 +230,8 @@ function shouldPromoteFromNoRoute(fragment, rule) {
hasLifecycleSignal(fragment, lowerText) ||
hasChainBreakSignal(lowerText) ||
hasPeriodImpactSignal(lowerText) ||
hasCausalSignal(lowerText);
hasCausalSignal(lowerText) ||
(hasBidirectionalValueFlowSignal(fragment, lowerText) && hasCounterpartyScopeSignal(fragment, lowerText));
const hasAnchor = hasAccountOrPeriodAnchor(fragment, lowerText) ||
fragment.candidate_labels.includes("cross_entity") ||
DOMAIN_LEXICAL_ANCHOR_PATTERN.test(lowerText);

View File

@ -1287,7 +1287,8 @@ function loadSessionDialog(runId: string, caseId: string): {
text: toStringSafe(item.text) ?? "",
created_at: toStringSafe(item.created_at),
trace_id: toStringSafe(item.trace_id),
reply_type: toStringSafe(item.reply_type)
reply_type: toStringSafe(item.reply_type),
debug: item.debug ?? null
}));
const turns = toArray(record.turns)
@ -1364,7 +1365,8 @@ function buildFallbackDialog(run: IndexedRun, caseId: string): {
text: userText,
created_at: null,
trace_id: null,
reply_type: null
reply_type: null,
debug: null
},
{
message_id: null,
@ -1372,7 +1374,8 @@ function buildFallbackDialog(run: IndexedRun, caseId: string): {
text: assistantSummaryParts.join("\n"),
created_at: null,
trace_id: toStringSafe(targetCase.trace_id),
reply_type: toStringSafe(targetCase.reply_type)
reply_type: toStringSafe(targetCase.reply_type),
debug: null
}
],
decomposition: [],

View File

@ -31,6 +31,7 @@ const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@ -92,6 +93,7 @@ function defaultCapabilityId(intent: AddressIntent): string {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -178,13 +180,17 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain"
) {
if (intent === "inventory_profitability_for_item") {
if (intent === "inventory_profitability_for_item" || intent === "inventory_margin_ranking_for_nomenclature") {
return {
enabled: true,
reason: "inventory_profitability_route_enabled"
reason:
intent === "inventory_margin_ranking_for_nomenclature"
? "inventory_margin_ranking_route_enabled"
: "inventory_profitability_route_enabled"
};
}
if (intent === "inventory_purchase_to_sale_chain") {
@ -284,6 +290,7 @@ export function resolveShadowRouteIntent(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"

View File

@ -149,6 +149,7 @@ export function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "open_contracts_confirmed_as_of_date" ||

View File

@ -1111,6 +1111,7 @@ function isInventoryTraceIntent(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -1135,6 +1136,7 @@ function usesRecipeDefaultLimit(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -1628,6 +1630,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
) {
return ["item"];
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
return ["period_from", "period_to"];
}
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}

View File

@ -2152,6 +2152,28 @@ function hasBidirectionalValueFlowComparisonSignal(text: string): boolean {
return hasIncomingCue && hasOutgoingCue && hasComparisonCue && (hasValueFlowCue || hasNetAmountCue);
}
function hasNomenclatureMarginRankingSignal(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
const hasNomenclatureCue =
/(?:номенклатур|товар|позици|ассортимент|sku|item|product|goods)/iu.test(normalized);
const hasRealizationCue =
/(?:реализован|реализац|продан|продаж|отгруж|41(?:[.,]0?1)?|90(?:[.,]\d{1,2})?|sales?|sold)/iu.test(
normalized
);
const hasMarginCue =
/(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(
normalized
);
const hasRankingCue =
/(?:высок|низк|топ|сам(?:ая|ый|ое|ые)|больш|меньш|ранж|рейтинг|high|low|top|rank|best|worst)/iu.test(
normalized
);
return hasNomenclatureCue && hasRealizationCue && hasMarginCue && hasRankingCue;
}
function hasVatPeriodInspectionBridgeSignal(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!/(?:ндс|vat)/iu.test(normalized)) {
@ -2263,6 +2285,14 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
);
}
if (hasNomenclatureMarginRankingSignal(normalized)) {
return unicodeBridgeResolution(
"inventory_margin_ranking_for_nomenclature",
"high",
"unicode_nomenclature_margin_ranking_bridge_signal_detected"
);
}
const hasOpenItemsAccountCue =
/(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(

View File

@ -2034,6 +2034,7 @@ function isOrganizationScopedInventoryIntent(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -2085,6 +2086,7 @@ function shouldDeferInventoryOrganizationClarification(
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -2455,6 +2457,7 @@ function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilte
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -2467,6 +2470,7 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -3132,6 +3136,9 @@ async function tryComposeLlmLimitedReply(input: {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
return null;
}
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return null;
}
if (!shouldUseLlmLimitedReply(input.category)) {
return null;
}
@ -3206,6 +3213,13 @@ function composeLimitedReply(input: {
)
);
const missingAnchorPhrase = missingAnchorLabels.length > 0 ? missingAnchorLabels.join(", ") : "контрагент, договор, счет или период";
if (input.intent === "inventory_margin_ranking_for_nomenclature" && input.category === "missing_anchor") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
const heading =
input.category === "empty_match"
? pickDeterministicVariant(headingSeed, [

View File

@ -992,6 +992,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "strict",
query_template: "inventory_trading_margin_proxy_profile"
},
{
recipe_id: "address_inventory_margin_ranking_for_nomenclature_v1",
intent: "inventory_margin_ranking_for_nomenclature",
purpose: "Rank realized nomenclature by bounded gross margin proxy for an explicit period using 41.01 purchase and sale document rows",
required_filters: ["period_from", "period_to"],
optional_filters: ["organization", "warehouse", "limit", "sort"],
default_limit: 800,
account_scope: ["41.01"],
account_scope_mode: "strict",
query_template: "inventory_margin_ranking_profile"
},
{
recipe_id: "address_inventory_purchase_to_sale_chain_v1",
intent: "inventory_purchase_to_sale_chain",
@ -1928,6 +1939,7 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_trading_margin_proxy_for_organization" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@ -2182,6 +2194,8 @@ export function buildAddressRecipePlan(
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_trading_margin_proxy_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_margin_ranking_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"

View File

@ -237,16 +237,6 @@ function formatMoneyRub(value: number): string {
return `${formatNumberWithDots(value, 2)}`;
}
function formatVatProbeStatusRu(status: VatDirectSourceProbeItem["status"]): string {
if (status === "ok") {
return "есть движения";
}
if (status === "empty") {
return "движения не найдены";
}
return "ошибка запроса";
}
function emphasizeNumericTokens(line: string): string {
if (!line) {
return line;
@ -524,6 +514,73 @@ function bankOperationEvidenceLine(
return `Основание 1С: ${parts.join("; ")}.`;
}
type BankOperationSemanticBucket =
| "commission"
| "deposit_or_credit"
| "tax_or_budget"
| "transfer_or_return"
| "other";
function classifyBankOperationSemanticBucket(row: ComposeStageRow): BankOperationSemanticBucket {
const text = [
row.registrator,
row.operation_kind,
row.payment_purpose,
row.contract,
row.comment
]
.map((item) => String(item ?? "").toLowerCase())
.join(" ");
if (/(?:комисс|тариф|эквайр|обслуживан)/iu.test(text)) {
return "commission";
}
if (/(?:депозит|кредит|займ|овердрафт|процент|ссуд)/iu.test(text)) {
return "deposit_or_credit";
}
if (/(?:налог|ндс|взнос|бюджет|фнс|пфр|страхов)/iu.test(text)) {
return "tax_or_budget";
}
if (/(?:возврат|перевод|перечислен|переброс|пополн|инкасс|перенос)/iu.test(text)) {
return "transfer_or_return";
}
return "other";
}
function bankOperationSemanticBucketLabel(bucket: BankOperationSemanticBucket): string {
if (bucket === "commission") {
return "комиссии и банковское обслуживание";
}
if (bucket === "deposit_or_credit") {
return "депозиты, кредиты или проценты";
}
if (bucket === "tax_or_budget") {
return "налоги и бюджетные платежи";
}
if (bucket === "transfer_or_return") {
return "переводы, возвраты или перебросы";
}
return "прочие банковские операции";
}
function summarizeBankOperationSemantics(rows: ComposeStageRow[]): string | null {
if (rows.length === 0) {
return null;
}
const counts = new Map<BankOperationSemanticBucket, number>();
for (const row of rows) {
const bucket = classifyBankOperationSemanticBucket(row);
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
}
const ranked = Array.from(counts.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 3);
if (ranked.length === 0) {
return null;
}
const parts = ranked.map(([bucket, count]) => `${bankOperationSemanticBucketLabel(bucket)}${count}`);
return `По смыслу это скорее финансовый/банковский контур: ${parts.join("; ")}.`;
}
function bankRoleBoundaryLine(userMessage: string | null | undefined, rows: ComposeStageRow[]): string | null {
const incomingBoundary = hasBankIncomingRoleBoundaryQuestion(userMessage);
const outgoingBoundary = hasBankOutgoingRoleBoundaryQuestion(userMessage);
@ -2666,29 +2723,24 @@ function formatPayablesEvidenceSuffix(item: PayablesConfirmedBalanceAggregate):
const suffix = item.documents.length > 1 ? ` (+${item.documents.length - 1})` : "";
parts.push(`документ: ${item.documents[0]}${suffix}`);
}
if (item.sourceRefs.length > 0) {
parts.push(`source refs: ${item.sourceRefs.slice(0, 2).join("; ")}`);
}
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function formatDebtMirrorGroupLine(item: DebtMirrorBalanceGroup): string {
const details = [
item.account ? `счет ${item.account}` : null,
item.contract ? `договор/аналитика: ${item.contract}` : null,
item.organization ? `организация: ${item.organization}` : null
].filter((part): part is string => Boolean(part));
const netText =
Math.abs(item.netAmount) <= 0.005
? "чисто: 0 ₽"
: item.netAmount > 0
? `чисто к получению: ${formatMoneyRub(item.netAmount)}`
: `чисто к оплате: ${formatMoneyRub(Math.abs(item.netAmount))}`;
return `${item.name}${details.length > 0 ? ` (${details.join(", ")})` : ""}: дебет ${formatMoneyRub(item.debitAmount)} / кредит ${formatMoneyRub(item.creditAmount)}, ${netText}.`;
return `${item.name}: встречная часть ${formatMoneyRub(Math.min(item.debitAmount, item.creditAmount))}, ${netText}.`;
}
function debtMirrorCleanScopeLabel(kind: "payables" | "receivables"): string {
return kind === "payables" ? "чистый долг к оплате" : "чистую дебиторку к получению";
function debtMirrorCleanScopeLabel(kind: "payables" | "receivables", grammaticalCase: "accusative" | "genitive"): string {
if (kind === "payables") {
return grammaticalCase === "genitive" ? "чистого долга к оплате" : "чистый долг к оплате";
}
return grammaticalCase === "genitive" ? "чистой дебиторки к получению" : "чистую дебиторку к получению";
}
function appendDebtMirrorCompactDisclosure(
@ -2699,12 +2751,13 @@ function appendDebtMirrorCompactDisclosure(
if (snapshot.mirroredOffsetAmount <= 0.005) {
return;
}
lines.push("", "Для сверки:");
lines.push(
`Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.`
`- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она не включена в ${debtMirrorCleanScopeLabel(kind, "accusative")}.`
);
const leadingMirror = snapshot.mirrorGroups[0] ?? null;
if (leadingMirror) {
lines.push(`Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`);
lines.push(`- Крупнейший встречный контрагент: ${formatDebtMirrorGroupLine(leadingMirror)}`);
}
}
@ -2719,9 +2772,12 @@ function appendDebtMirrorDisclosure(
lines.push("");
lines.push("Встречные остатки к сверке");
lines.push(
`- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она исключена из ${debtMirrorCleanScopeLabel(kind)}.`
`- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она исключена из ${debtMirrorCleanScopeLabel(kind, "genitive")}.`
);
lines.push(...snapshot.mirrorGroups.slice(0, 3).map((item, index) => `${index + 1}. ${formatDebtMirrorGroupLine(item)}`));
const leadingMirror = snapshot.mirrorGroups[0] ?? null;
if (leadingMirror) {
lines.push(`- Крупнейший встречный контрагент: ${formatDebtMirrorGroupLine(leadingMirror)}`);
}
}
function deriveOperationalYearWindow(
@ -3734,7 +3790,6 @@ function composeFactualReplyBody(
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;
@ -3743,67 +3798,20 @@ function composeFactualReplyBody(
const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const formatForecastMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const periodWindowLabel =
options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const lines = [
`Коротко: ориентир по НДС к уплате за ${periodWindowLabel ?? "доступный срез"}${formatForecastMoney(vatToPay)}.`,
`Если смотреть на возможный перенос или переплату, получается ${formatForecastMoney(carryoverOrOverpayment)}.`,
"Это предварительная оценка по оборотам 68.02*/19*, а не подтвержденная сумма налога по декларации.",
"Это предварительная оценка по бухгалтерским оборотам НДС, а не подтвержденная сумма налога по декларации.",
"",
"Что вошло в расчет:",
`- Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`,
`- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`,
`- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`,
`- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`,
`- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.`
`- НДС начисленный: ${formatForecastMoney(turnover68Credit)}.`,
`- Уменьшение/вычеты по НДС: ${formatForecastMoney(turnover68Debit)}.`,
`- Нетто НДС: ${formatForecastMoney(netVat)}.`
];
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
const statusRank = (status: VatDirectSourceProbeItem["status"]): number =>
status === "ok" ? 0 : status === "empty" ? 1 : 2;
const orderedProbeRows = [...vatProbe.probedSources].sort(
(a, b) =>
statusRank(a.status) - statusRank(b.status) ||
a.fullName.localeCompare(b.fullName, "ru")
);
const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error");
const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6);
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push(
"",
"Покрытие VAT-источников в 1С:",
`- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`,
`- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`
);
if (visibleProbeRows.length > 0) {
lines.push(
...visibleProbeRows.map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
const extra =
item.status === "ok"
? item.lastPeriod
? ` | последнее движение: ${item.lastPeriod}`
: ""
: item.status === "error" && item.error
? ` | ошибка: ${item.error}`
: "";
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${extra}`;
})
);
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия.");
} else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*.");
}
if (!vatActivityDetected) {
lines.push(
`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(
@ -3883,18 +3891,15 @@ function composeFactualReplyBody(
const purchaseVat = rowsByMarker.get("VAT_BOOK_PURCHASES") ?? 0;
const netVat = salesVat - purchaseVat;
const vatToPay = Math.max(0, netVat);
const carryoverOrOverpayment = Math.max(0, -netVat);
const periodWindowLabel =
options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`,
`Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`,
"Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.",
"Расчет сделан по книгам продаж и покупок.",
"",
"Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
@ -3904,32 +3909,6 @@ function composeFactualReplyBody(
`- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.`
];
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push(
"",
"Покрытие VAT-источников в 1С:",
`- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`,
`- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`
);
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников.");
} else if (vatProbe && vatProbe.status === "error") {
lines.push(
"",
"Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.",
"Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия."
);
if (vatProbe.errors.length > 0) {
lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
}
if (rows.length === 0) {
lines.push(
"",
@ -4003,63 +3982,24 @@ function composeFactualReplyBody(
const lines: string[] = [
`Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный срез НДС к уплате по состоянию на дату.",
"",
"Блок 2. Что учтено",
"Что учтено:",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счетам НДС к уплате (68*)."
"- Контур: остатки по счетам НДС к уплате."
];
const vatProbe = options.vatDirectSourceProbe ?? null;
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push(
"",
"Блок 2.1. Проверка VAT-источников в 1С",
`- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`
);
if (vatProbe.probedSources.length > 0) {
lines.push(
...vatProbe.probedSources.slice(0, 4).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
const suffix =
item.status === "ok"
? `${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.sampleRegistrator ? ` | пример: ${item.sampleRegistrator}` : ""}`
: item.status === "error" && item.error
? ` | ошибка: ${item.error}`
: "";
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${suffix}`;
})
);
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
} else if (vatProbe && vatProbe.status === "error") {
lines.push(
"",
"Блок 2.1. Проверка VAT-источников в 1С",
"- Дополнительная проверка VAT-источников завершилась ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."
);
}
lines.push(
"",
"Блок 3. Сводка",
"Сводка:",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`,
"",
"Блок 4. Подтвержденные позиции"
"Подтвержденные позиции:"
);
if (accountRows.length > 0) {
lines.push(
...accountRows.slice(0, 12).map((item, index) => {
const refs = Array.from(item.refs).slice(0, 2).join("; ");
return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`;
return `${index + 1}. ${item.account} — остаток НДС к уплате: ${formatMoneyRub(item.total)}, операций: ${formatNumberWithDots(item.operations)}.`;
})
);
} else {
@ -4386,7 +4326,6 @@ function composeFactualReplyBody(
},
{ supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }
);
if (isDirectBalanceQuestion(options.userMessage)) {
const leading = confirmedBalances[0] ?? null;
const compactLines: string[] = leading
@ -4399,8 +4338,7 @@ function composeFactualReplyBody(
if (leading) {
compactLines.push(
...confirmedBalances.slice(0, 5).map((item, index) => {
const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : "";
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`;
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.).`;
})
);
if (confirmedBalances.length > 5) {
@ -4454,7 +4392,7 @@ function composeFactualReplyBody(
if (confirmedBalances.length > 0) {
lines.push(
...confirmedBalances.slice(0, 10).flatMap((item, index) => [
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`,
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`,
""
])
);
@ -4512,8 +4450,7 @@ function composeFactualReplyBody(
if (leading) {
compactLines.push(
...confirmedBalances.slice(0, 5).map((item, index) => {
const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : "";
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`;
return `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.).`;
})
);
if (confirmedBalances.length > 5) {
@ -4534,49 +4471,31 @@ function composeFactualReplyBody(
}
const lines: string[] = [
`Коротко: подтвержденная дебиторская задолженность на ${formatDateRu(receivablesAsOfDate)}${formatMoneyRub(totalOutstandingAmount)}.`,
"Это подтвержденный срез дебиторской задолженности, а не эвристический shortlist."
`Коротко: на ${formatDateRu(receivablesAsOfDate)} нам должны ${formatMoneyRub(totalOutstandingAmount)}.`
];
lines.push("");
lines.push("Что учтено");
lines.push(`- Дата среза: ${formatDateRu(receivablesAsOfDate)}.`);
if (periodScopeLine) {
lines.push(periodScopeLine);
}
lines.push("- Контур: дебиторская задолженность по счетам 62/76.");
if (carryoverLine) {
lines.push(carryoverLine);
if (periodScopeLine || carryoverLine) {
lines.push("");
lines.push("Граница ответа:");
if (periodScopeLine) {
lines.push(periodScopeLine);
}
if (carryoverLine) {
lines.push(carryoverLine);
}
}
lines.push("");
lines.push("Сводка");
lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`);
lines.push(`- Контрагентов с подтвержденным остатком к получению: ${formatNumberWithDots(confirmedBalances.length)}.`);
appendDebtMirrorDisclosure(lines, balanceSnapshot, "receivables");
lines.push("");
lines.push("Категории дебиторской задолженности");
lines.push(`- ${receivablesCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}.`);
lines.push(`- ${receivablesCategoryLabel("bank_or_credit")}: ${formatNumberWithDots(categoryCounts.bank_or_credit)}.`);
lines.push(`- ${receivablesCategoryLabel("tax_or_state")}: ${formatNumberWithDots(categoryCounts.tax_or_state)}.`);
lines.push(`- ${receivablesCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}.`);
lines.push("");
lines.push("Подтвержденные позиции к получению");
lines.push("К получению:");
if (confirmedBalances.length > 0) {
lines.push(
...confirmedBalances.slice(0, 10).flatMap((item, index) => [
`${index + 1}. ${item.name} | категория: ${receivablesCategoryLabel(item.category)} | остаток к получению: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`,
""
])
...confirmedBalances.slice(0, 10).map(
(item, index) => `${index + 1}. ${item.name}${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.).`
)
);
if (lines[lines.length - 1] === "") {
lines.pop();
}
} else {
lines.push("- Подтвержденной открытой дебиторской задолженности на дату среза не найдено.");
}
appendDebtMirrorDisclosure(lines, balanceSnapshot, "receivables");
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
@ -4606,7 +4525,7 @@ function composeFactualReplyBody(
: null;
const formatHeuristicItem = (item: PayablesCounterpartyRiskAggregate, index: number): string =>
`${index + 1}. ${item.name} | сумма к проверке: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
`${index + 1}. ${item.name} | сумма к проверке: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const pushCategorySlice = (
lines: string[],
@ -4723,7 +4642,7 @@ function composeFactualReplyBody(
"Блок 5. Крупнейшие подтвержденные позиции к оплате (по сумме остатка):",
...confirmedBalances.slice(0, 10).map(
(item, index) =>
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoneyRub(item.outstandingAmount)} | операций в срезе: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoneyRub(item.outstandingAmount)} | операций в срезе: ${formatNumberWithDots(item.operations)}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`
)
];
appendDebtMirrorDisclosure(lines, balanceSnapshot, "payables");
@ -5013,15 +4932,41 @@ function composeFactualReplyBody(
);
const counterparty = resolvePreferredCounterpartyDisplayLabel(options.counterpartyHint, rowCounterparties);
const roleBoundary = bankRoleBoundaryLine(options.userMessage, rows);
const visibleRows = rows.slice(0, Math.min(rows.length, 5));
const visibleRows = [...rows]
.sort(
(left, right) =>
Math.abs(right.amount ?? 0) - Math.abs(left.amount ?? 0) ||
(String(right.period ?? "").localeCompare(String(left.period ?? ""), "ru"))
)
.slice(0, Math.min(rows.length, 5));
const semanticSummary = summarizeBankOperationSemantics(rows);
const compactRoleAnswer = /(?:клиент|поставщик|финансов|роль|коротко)/iu.test(String(options.userMessage ?? ""));
const compactEvidenceRows = visibleRows.map((row, index) => {
const direction = bankOperationDirectionLabel(bankOperationDirection(row));
const amount = formatMoneyRub(row.amount ?? 0);
const period = row.period ? formatDateRu(row.period) : "дата не указана";
const operationKind = String(row.operation_kind ?? "").trim();
const paymentPurpose = String(row.payment_purpose ?? "").trim();
const detail = operationKind || paymentPurpose
? ` | ${[operationKind, paymentPurpose].filter(Boolean).join("; ")}`
: "";
return `${index + 1}. ${period} | ${direction} | ${amount}${detail}`;
});
const lines = [
`Коротко: найдено банковских операций${counterparty ? ` по ${counterparty}` : " по контрагенту"}${rows.length}.`,
summarizeBankOperationDirections(rows),
roleBoundary ?? "Показываю подтвержденные банковские операции из текущего среза.",
bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)),
...formatTopRows(visibleRows, visibleRows.length)
...(semanticSummary ? [semanticSummary] : [])
];
if (rows.length > visibleRows.length) {
if (!compactRoleAnswer) {
lines.push(
bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)),
"Примеры строк 1С:",
...compactEvidenceRows
);
}
lines.push("Следующий шаг: могу отдельно разложить назначения платежа, договоры или отделить банковский контур от клиентского/поставщицкого.");
if (!compactRoleAnswer && rows.length > visibleRows.length) {
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length}; полный список остается в подтвержденном срезе.`);
}
return {

View File

@ -120,6 +120,26 @@ function findFocusedCounterpartyValuePoint(
return profileRows.length === 1 ? profileRows[0] : null;
}
function hasProfitAmbiguityCue(normalizedQuestion: string): boolean {
return /(?:заработ|прибыл|прибыль|доход|выручк)/iu.test(normalizedQuestion);
}
function buildCashflowBoundaryLine(isSupplier: boolean): string {
return isSupplier
? "Граница ответа: это подтвержденный денежный поток по поставщику, а не итоговая задолженность."
: "Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.";
}
function buildCashflowNextStepLine(isSupplier: boolean, normalizedQuestion: string): string | null {
if (isSupplier) {
return "Следующий шаг: могу отдельно показать остаток долга, просрочку или расшифровку по документам.";
}
if (hasProfitAmbiguityCue(normalizedQuestion)) {
return "Следующий шаг: могу отдельно проверить чистую прибыль по закрытию 90/91/99.";
}
return "Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.";
}
export function composeCounterpartyAnalyticsReply(
intent: AddressIntent,
rows: ComposeStageRow[],
@ -546,6 +566,8 @@ export function composeCounterpartyAnalyticsReply(
const semanticSingleBestCounterparty =
focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList;
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
const byCounterparty = new Map<string, CounterpartyValuePoint>();
const byYear = new Map<number, CounterpartyYearPoint>();
@ -655,10 +677,12 @@ export function composeCounterpartyAnalyticsReply(
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
: "за доступное время";
const directAnswerLine = isSupplier
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`;
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям.`;
const summaryLines = [
directAnswerLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Контрагент в выборке: ${focusedCounterparty.name}.`,
@ -678,11 +702,10 @@ export function composeCounterpartyAnalyticsReply(
options.periodFrom && options.periodTo
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
const directAnswerLine = isSupplier
? periodLine
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
const summaryLines = [
directAnswerLine,
periodLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Операций в выборке: ${totalOperations}.`,
@ -709,11 +732,17 @@ export function composeCounterpartyAnalyticsReply(
const strongestYear = visible[0];
const directAnswerLine = isSupplier
? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`;
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`;
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (!isSupplier) {
lines.unshift(cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.unshift(cashflowNextStepLine);
}
}
lines.unshift(directAnswerLine);
lines.push(
...visible.map(
@ -829,11 +858,17 @@ export function composeCounterpartyAnalyticsReply(
const directAnswerLine = singleCandidateOnly
? isSupplier
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
lines.unshift(directAnswerLine);
if (!isSupplier) {
lines.splice(1, 0, cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.splice(2, 0, cashflowNextStepLine);
}
}
}
lines.push(
...visible.map((item, index) => {

View File

@ -423,6 +423,7 @@ function isInventoryIntent(intent: AddressIntent | undefined): boolean {
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -438,6 +439,7 @@ function isInventoryDrilldownFrameIntent(intent: AddressIntent | undefined): boo
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -449,6 +451,7 @@ function isInventoryLifecycleHistoryIntent(intent: AddressIntent | undefined): b
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain"
);
@ -798,11 +801,32 @@ export function hasInventoryPurchaseDateVatBridgeCue(text: string): boolean {
);
}
export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase()
.replace(/ё/g, "е");
if (!normalized.trim()) {
return false;
}
const wantsFoundRows =
/(?:покажи|показать|выведи|дай|раскрой|show|list|РїРѕРєР°РРё|показаССЊ|РІСведи|РґР°Р|раскроР)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|РЅР°Рденн|СЃССЂРѕРє|реализаС|себесСРѕРёРјРѕСЃСРЅ|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себесСРѕРёРјРѕСЃСРЅ|реализаС|марР|РїСЂРёР±СР»|номенклаС)/iu.test(normalized);
const account41Not01 =
/\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вмесСРѕ|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
}
export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim();
if (!normalized) {
return false;
}
if (hasInventoryMarginRankingFollowupCue(normalized)) {
return true;
}
if (
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
normalized
@ -1115,6 +1139,7 @@ function mergeFollowupFilters(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@ -1175,6 +1200,7 @@ function mergeFollowupFilters(
(intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date")
@ -1412,6 +1438,7 @@ function mergeFollowupFilters(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date" ||
@ -1424,6 +1451,9 @@ function mergeFollowupFilters(
const currentContractExplicit = toNonEmptyString(merged.contract);
const currentItemExplicit = toNonEmptyString(merged.item);
const currentAccountExplicit = toNonEmptyString(merged.account);
const currentAccountRefinesMarginDomain =
intent === "inventory_margin_ranking_for_nomenclature" &&
hasInventoryMarginRankingFollowupCue(userMessage);
const shouldSuppressGenericPeriodCarryover =
(Boolean(currentCounterpartyExplicit) &&
!isLowQualityCounterpartyAnchor(currentCounterpartyExplicit) &&
@ -1432,7 +1462,7 @@ function mergeFollowupFilters(
!isLowQualityContractAnchor(currentContractExplicit) &&
currentContractExplicit !== previousContract) ||
(Boolean(currentItemExplicit) && currentItemExplicit !== previousItem) ||
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount);
(Boolean(currentAccountExplicit) && currentAccountExplicit !== previousAccount && !currentAccountRefinesMarginDomain);
const vatRelativeMonthFollowup =
relativeMonthFromFollowupYear &&
(intent === "vat_payable_confirmed_as_of_date" ||
@ -1488,6 +1518,22 @@ function mergeFollowupFilters(
reasons.push("period_from_followup_context");
}
if (
intent === "inventory_margin_ranking_for_nomenclature" &&
previousHasPeriod &&
hasInventoryMarginRankingFollowupCue(userMessage) &&
!hasExplicitPeriodInMessage &&
!hasExplicitCurrentDateInMessage
) {
if (previousPeriodFrom && merged.period_from !== previousPeriodFrom) {
merged.period_from = previousPeriodFrom;
}
if (previousPeriodTo && merged.period_to !== previousPeriodTo) {
merged.period_to = previousPeriodTo;
}
reasons.push("period_from_followup_context");
}
if (
!currentHasPeriod &&
previousHasPeriod &&
@ -1563,6 +1609,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
inventory_on_hand_as_of_date: ["as_of_date"],
inventory_margin_ranking_for_nomenclature: ["period_from", "period_to"],
inventory_profitability_for_item: ["item"],
open_contracts_confirmed_as_of_date: ["as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
@ -1648,6 +1695,26 @@ function deriveIntentWithFollowupContext(
previousCounterpartyLaneActive && !hasExplicitInventoryItemReference;
const inventoryPurchaseDateVatBridge =
inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage);
const marginRankingLineageActive =
sourceIntent === "inventory_margin_ranking_for_nomenclature" ||
fallbackIntent === "inventory_margin_ranking_for_nomenclature" ||
followupContext.root_intent === "inventory_margin_ranking_for_nomenclature";
if (
marginRankingLineageActive &&
hasInventoryMarginRankingFollowupCue(normalizedMessage) &&
(detectedIntent.intent === "unknown" ||
detectedIntent.intent === "account_balance_snapshot" ||
detectedIntent.intent === "documents_forming_balance" ||
detectedIntent.intent === "inventory_margin_ranking_for_nomenclature" ||
detectedIntent.intent === sourceIntent)
) {
return {
intent: "inventory_margin_ranking_for_nomenclature",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_margin_ranking_followup_context"]
};
}
if (
inventoryPurchaseDateVatBridge &&

View File

@ -162,6 +162,81 @@ function inventoryProfitabilityPeriodLabel(options: InventoryComposeOptions, dep
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
function asksForInventoryCostBaseRows(userMessage: string | null | undefined): boolean {
const text = String(userMessage ?? "").toLowerCase();
if (!/(?:покажи|показать|выведи|вывести|дай|дать|раскрой|раскрыть|строк|строки|строку|баз)/iu.test(text)) {
return false;
}
return /(?:себестоимостн|себестоимост|себестоим|закупочн|закупк|90\.02|\b41\b|баз)/iu.test(text);
}
interface InventoryMarginRankingEntry {
item: string;
revenue: number;
costProxy: number;
spread: number;
marginPct: number | null;
saleQuantity: number;
purchaseQuantity: number;
saleDocuments: number;
purchaseDocuments: number;
}
function inventoryRowItemLabel(row: ComposeStageRow, deps: InventoryReplyDeps): string | null {
return deps.summarizeInventoryTraceRows([row]).item;
}
function buildInventoryMarginRankingEntries(rows: ComposeStageRow[], deps: InventoryReplyDeps): InventoryMarginRankingEntry[] {
const byItem = new Map<string, { item: string; saleRows: ComposeStageRow[]; purchaseRows: ComposeStageRow[] }>();
for (const row of rows) {
const item = inventoryRowItemLabel(row, deps);
if (!item) {
continue;
}
const key = item.trim().toLocaleLowerCase("ru");
const current = byItem.get(key) ?? { item, saleRows: [], purchaseRows: [] };
if (deps.isInventorySaleMovement(row)) {
current.saleRows.push(row);
}
if (deps.isInventoryPurchaseMovement(row)) {
current.purchaseRows.push(row);
}
byItem.set(key, current);
}
return Array.from(byItem.values())
.map((entry) => {
const revenue = sumInventoryRowAmount(entry.saleRows);
const costProxy = sumInventoryRowAmount(entry.purchaseRows);
const spread = revenue - costProxy;
return {
item: entry.item,
revenue,
costProxy,
spread,
marginPct: revenue > 0 ? (spread / revenue) * 100 : null,
saleQuantity: sumInventoryRowQuantity(entry.saleRows),
purchaseQuantity: sumInventoryRowQuantity(entry.purchaseRows),
saleDocuments: entry.saleRows.length,
purchaseDocuments: entry.purchaseRows.length
};
})
.filter((entry) => entry.revenue > 0 || entry.costProxy > 0);
}
function formatInventoryMarginRankingLine(
entry: InventoryMarginRankingEntry,
index: number,
deps: InventoryReplyDeps
): string {
return `${index + 1}. ${entry.item} — выручка ${deps.formatMoneyRub(entry.revenue)}, себестоимостная база ${deps.formatMoneyRub(
entry.costProxy
)}, валовая разница ${deps.formatMoneyRub(entry.spread)}, маржа ${formatInventoryPercent(
entry.marginPct,
deps.formatNumberWithDots
)}.`;
}
export function composeInventoryReply(
intent: AddressIntent,
rows: ComposeStageRow[],
@ -175,17 +250,23 @@ export function composeInventoryReply(
const uniqueWarehouses = deps.uniqueStrings(
positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)
);
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const directAnswerLine =
positions.length > 0
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.`
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines: string[] = [directAnswerLine];
if (positions.length > 0) {
const visiblePositionsLimit = 6;
const visiblePositions = positions.slice(0, visiblePositionsLimit);
const positionsByAmount = [...positions].sort((left, right) => {
const amountDelta = right.amount - left.amount;
if (amountDelta !== 0) {
return amountDelta;
}
return left.item.localeCompare(right.item, "ru");
});
const visiblePositions = positionsByAmount.slice(0, visiblePositionsLimit);
appendInventorySection(
lines,
"Позиции:",
@ -213,11 +294,14 @@ export function composeInventoryReply(
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
`Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
]);
if (rows.length !== positions.length) {
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
}
if (positions.length > 0) {
lines.push("- Следующий шаг: могу раскрыть полный список, разложить остатки по складам или сравнить с другой датой.");
}
return positions.length > 0
? buildFactualListReply(lines, buildConfirmedBalanceSemantics("strong"))
@ -539,6 +623,129 @@ export function composeInventoryReply(
: buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false));
}
if (intent === "inventory_margin_ranking_for_nomenclature") {
const entries = buildInventoryMarginRankingEntries(rows, deps);
const confirmedEntries = entries.filter((entry) => entry.revenue > 0 && entry.costProxy > 0);
const highMargin = [...confirmedEntries]
.sort((left, right) => right.spread - left.spread || (right.marginPct ?? -Infinity) - (left.marginPct ?? -Infinity))
.slice(0, 5);
const lowMargin = [...confirmedEntries]
.sort((left, right) => left.spread - right.spread || (left.marginPct ?? Infinity) - (right.marginPct ?? Infinity))
.slice(0, 5);
const salesWithoutCost = entries.filter((entry) => entry.revenue > 0 && entry.costProxy <= 0);
const purchasesWithoutSales = entries.filter((entry) => entry.costProxy > 0 && entry.revenue <= 0);
const periodLabel = inventoryProfitabilityPeriodLabel(options, deps);
const totalRevenue = entries.reduce((sum, entry) => sum + entry.revenue, 0);
const totalCostProxy = entries.reduce((sum, entry) => sum + entry.costProxy, 0);
const totalSpread = totalRevenue - totalCostProxy;
if (confirmedEntries.length === 0) {
const costBaseRowsRequested = asksForInventoryCostBaseRows(options.userMessage);
const lines: string[] = [
costBaseRowsRequested && purchasesWithoutSales.length === 0
? `За период ${periodLabel} подтвержденных строк себестоимостной базы по реализованной номенклатуре не найдено.`
: `За период ${periodLabel} рейтинг прибыльности номенклатуры построить нельзя.`
];
const findings: string[] = [];
if (salesWithoutCost.length > 0) {
const salesCount = deps.formatNumberWithDots(salesWithoutCost.length);
const salesItemPhrase =
salesWithoutCost.length === 1 ? "1 номенклатурной позиции" : `${salesCount} номенклатурным позициям`;
findings.push(
`Есть реализация по ${salesItemPhrase}.`
);
findings.push(
salesWithoutCost.length === 1
? "Подтвержденной себестоимости реализации по этой позиции не найдено."
: "Подтвержденной себестоимости реализации по этим позициям не найдено."
);
findings.push("Поэтому валовую прибыль и маржинальность честно посчитать нельзя.");
}
if (costBaseRowsRequested && purchasesWithoutSales.length === 0) {
findings.push("Строк себестоимости реализации / себестоимостной базы для показа нет.");
}
if (purchasesWithoutSales.length > 0) {
const purchaseCount = deps.formatNumberWithDots(purchasesWithoutSales.length);
const purchaseItemPhrase =
purchasesWithoutSales.length === 1 ? "1 позиции" : `${purchaseCount} позициям`;
findings.push(
purchasesWithoutSales.length === 1
? `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ней в периоде не найдено.`
: `Есть себестоимостная база по ${purchaseItemPhrase}, но реализации по ним в периоде не найдено.`
);
}
if (entries.length === 0) {
findings.push("В доступной выборке нет достаточных строк реализации и себестоимости по номенклатуре.");
}
appendInventoryBulletSection(lines, "Что нашлось:", findings);
lines.push(
`Вывод: за период ${periodLabel} нет достаточной базы для рейтинга «высокая / низкая прибыль» по номенклатуре.`
);
const nextActions: string[] = [];
if (salesWithoutCost.length > 0) {
nextActions.push("показать найденные реализации за этот период;");
}
if (purchasesWithoutSales.length > 0) {
nextActions.push("показать найденные строки себестоимостной базы за этот период;");
}
nextActions.push(
"расширить период до квартала или года;",
"попробовать строгий расчет по проводкам 90.01 / 90.02;",
"построить управленческий proxy по закупочным документам, если такой способ допустим для вашей проверки."
);
appendInventoryBulletSection(lines, "Что можно сделать дальше:", nextActions);
appendInventoryBulletSection(lines, "Граница ответа:", [
"Прибыльность номенклатуры считаю только когда есть реализация и подтвержденная себестоимость реализации.",
"Это не показатель чистой прибыли и не замена закрытию месяца."
]);
return buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics(entries.length > 0 ? "medium" : "weak", false));
}
const directAnswerLine =
confirmedEntries.length > 0
? `За период ${periodLabel} собран рейтинг реализованной номенклатуры по валовой маржинальности: выручка ${deps.formatMoneyRub(
totalRevenue
)}, себестоимостная база ${deps.formatMoneyRub(totalCostProxy)}, расчетная валовая разница ${deps.formatMoneyRub(
totalSpread
)}.`
: `За период ${periodLabel} не удалось подтвердить рейтинг прибыльности номенклатуры: нужны одновременно строки реализации и закупочного/себестоимостного следа по товарам.`;
const lines: string[] = [directAnswerLine];
if (highMargin.length > 0) {
appendInventorySection(
lines,
"Высокая валовая маржинальность:",
highMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps))
);
}
if (lowMargin.length > 0) {
appendInventorySection(
lines,
"Низкая или отрицательная валовая маржинальность:",
lowMargin.map((entry, index) => formatInventoryMarginRankingLine(entry, index, deps))
);
}
const boundaryLines = [
"Это управленческий расчет валовой маржинальности по реализации и доступной себестоимостной базе, не показатель чистой прибыли.",
"Для строгого бухгалтерского расчета нужны проводки 90.01 / 90.02 и закрытие себестоимости; этот ответ не подменяет закрытие месяца."
];
if (salesWithoutCost.length > 0) {
boundaryLines.push(
`По ${deps.formatNumberWithDots(salesWithoutCost.length)} позициям есть продажи, но нет подтвержденной себестоимости реализации — их нельзя честно ранжировать по прибыли.`
);
}
if (purchasesWithoutSales.length > 0) {
boundaryLines.push(
`По ${deps.formatNumberWithDots(purchasesWithoutSales.length)} позициям есть себестоимостная база без реализации в этом периоде.`
);
}
appendInventoryBulletSection(lines, "Граница ответа:", boundaryLines);
return buildFactualSummaryReply(
lines,
buildConfirmedBalanceSemantics(confirmedEntries.length > 0 ? "strong" : entries.length > 0 ? "medium" : "weak", confirmedEntries.length > 0)
);
}
if (intent === "inventory_profitability_for_item") {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row));

View File

@ -84,6 +84,14 @@ function sessionOrganizationName(
return toNonEmptyString(scope?.selectedOrganization) ?? toNonEmptyString(scope?.activeOrganization);
}
function sessionKnownOrganizations(
sessionOrganizationScope: BuildAssistantAddressOrchestrationRuntimeInput["sessionOrganizationScope"]
): unknown[] {
const scope = toRecordObject(sessionOrganizationScope);
const knownOrganizations = scope?.knownOrganizations;
return Array.isArray(knownOrganizations) ? knownOrganizations : [];
}
function predecomposeOrganizationName(
predecomposeContract: Record<string, unknown> | null,
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
@ -130,6 +138,122 @@ function mergeOrganizationIntoDiscoveryFollowupContext(
return base;
}
function compactLower(value: unknown): string {
return String(value ?? "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function hasCompactCashflowFollowupSignal(text: string | null): boolean {
const value = compactLower(text);
if (!value) {
return false;
}
return /(?:\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u043f\u0440\u0438\u0448\p{L}*[\s\S]{0,80}\u0443\u0448\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b)/iu.test(value);
}
function dateScopeToFilterWindow(dateScope: string | null): Record<string, string> | null {
if (!dateScope) {
return null;
}
if (/^(?:19|20)\d{2}$/.test(dateScope)) {
return {
period_from: `${dateScope}-01-01`,
period_to: `${dateScope}-12-31`
};
}
if (/^(?:19|20)\d{2}-\d{2}-\d{2}$/.test(dateScope)) {
return { as_of_date: dateScope };
}
return null;
}
function looksLikeReliableOrganizationScope(value: string | null): boolean {
const text = compactLower(value);
if (!text) {
return false;
}
if (/(?:\u0440\u0430\u0437\u0431\u0438\u0432|\u0440\u0430\u0437\u0440\u0435\u0437|\u0432\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043d\u0435\u0442\u0442\u043e|\u0442\u043e\u043f)/iu.test(text)) {
return false;
}
return /(?:^|[\s"'\u00ab])(?:\u043e\u043e\u043e|\u0438\u043f|\u043f\u0430\u043e|\u0430\u043e|\u0437\u0430\u043e)(?:[\s"'\u00bb]|$)|\u043e\u0431\u0449\u0435\u0441\u0442\u0432\p{L}*\s+\u0441\s+\u043e\u0433\u0440\u0430\u043d\u0438\u0447\p{L}*\s+\u043e\u0442\u0432\p{L}*|\b(?:llc|inc|corp)\b/iu.test(text);
}
function inferBusinessOverviewDiscoveryContextFromSessionItems(
sessionItems: unknown[],
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"]
): Record<string, unknown> | null {
for (let index = sessionItems.length - 1; index >= 0; index -= 1) {
const item = toRecordObject(sessionItems[index]);
if (toNonEmptyString(item?.role) !== "assistant") {
continue;
}
const debug = toRecordObject(item?.debug);
const entryPoint = toRecordObject(debug?.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input);
const dataNeedGraph = toRecordObject(turnInput?.data_need_graph);
if (toNonEmptyString(dataNeedGraph?.business_fact_family) !== "business_overview") {
continue;
}
const turnMeaningRef = toRecordObject(turnInput?.turn_meaning_ref);
const filterWindow = dateScopeToFilterWindow(toNonEmptyString(turnMeaningRef?.explicit_date_scope));
if (!filterWindow) {
continue;
}
const previousFilters: Record<string, unknown> = { ...filterWindow };
const organization = toNonEmptyString(turnMeaningRef?.explicit_organization_scope);
if (looksLikeReliableOrganizationScope(organization)) {
previousFilters.organization = organization;
}
const rankingNeed = toNonEmptyString(dataNeedGraph?.ranking_need);
return {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: previousFilters,
root_filters: { ...previousFilters },
previous_seeded_ranking_need: rankingNeed,
previous_intent: "business_overview",
root_intent: "business_overview"
};
}
return null;
}
function mergeBusinessOverviewDateContextForCompactCashflow(input: {
userMessage: string;
followupContext: Record<string, unknown> | null;
sessionItems: unknown[];
toNonEmptyString: BuildAssistantAddressOrchestrationRuntimeInput["toNonEmptyString"];
}): Record<string, unknown> | null {
if (!hasCompactCashflowFollowupSignal(input.userMessage)) {
return input.followupContext;
}
const existingFilters = toRecordObject(input.followupContext?.previous_filters);
if (
input.toNonEmptyString(existingFilters?.period_from) ||
input.toNonEmptyString(existingFilters?.period_to) ||
input.toNonEmptyString(existingFilters?.as_of_date)
) {
return input.followupContext;
}
const inferred = inferBusinessOverviewDiscoveryContextFromSessionItems(input.sessionItems, input.toNonEmptyString);
if (!inferred) {
return input.followupContext;
}
return {
...inferred,
...(input.followupContext ?? {}),
previous_filters: {
...(toRecordObject(inferred.previous_filters) ?? {}),
...(toRecordObject(input.followupContext?.previous_filters) ?? {})
},
root_filters: {
...(toRecordObject(inferred.root_filters) ?? {}),
...(toRecordObject(input.followupContext?.root_filters) ?? {})
}
};
}
function hasSelectedObjectInventorySignal(text: string | null): boolean {
return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ним|selected\s+object)/iu.test(
String(text ?? "")
@ -157,6 +281,7 @@ function isInventorySelectedObjectOrRootIntent(intent: string | null): boolean {
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_margin_ranking_for_nomenclature" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date"
@ -175,6 +300,18 @@ function isGenericCanonicalDriftIntent(intent: string | null): boolean {
);
}
function hasInventoryMarginRankingAccountCorrectionCue(text: string | null): boolean {
const value = String(text ?? "").toLowerCase();
if (!value.trim()) {
return false;
}
return (
/\b41(?:[.,]\d{1,2})?\b/iu.test(value) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(value) &&
/(?:\u0430\s+\u043d\u0435|\u043d\u0435|\u0432\u043c\u0435\u0441\u0442\u043e|not|instead)/iu.test(value)
);
}
function hasSameDateFollowupSignal(text: string | null): boolean {
return /(?:эту\s+же\s+дат(?:у|е|ой)|ту\s+же\s+дат(?:у|е|ой)|same\s+date)/iu.test(String(text ?? ""));
}
@ -240,6 +377,12 @@ function shouldPreferRawFollowupMessage(
const hasInventoryFrameCarryover =
isInventorySelectedObjectOrRootIntent(previousIntent) ||
isInventorySelectedObjectOrRootIntent(rootIntent);
const hasInventoryMarginRankingCarryover =
previousIntent === "inventory_margin_ranking_for_nomenclature" ||
rootIntent === "inventory_margin_ranking_for_nomenclature";
const hasInventoryMarginRankingAccountCorrection =
hasInventoryMarginRankingCarryover &&
[rawMessage, canonicalMessage].some((message) => hasInventoryMarginRankingAccountCorrectionCue(message));
const hasDocumentCarryover =
previousIntent === "list_documents_by_counterparty" || previousIntent === "list_documents_by_contract";
@ -263,6 +406,13 @@ function shouldPreferRawFollowupMessage(
return true;
}
if (
hasInventoryMarginRankingAccountCorrection &&
(intent === "account_balance_snapshot" || intent === "documents_forming_balance" || intent === "unknown")
) {
return true;
}
return (
(hasSelectedObjectInventorySignal(rawMessage) || hasInventoryItemCarryover) &&
(hasSelectedObjectInventoryActionCue(rawMessage) || hasShortInventoryPurchaseFollowupCue(rawMessage)) &&
@ -363,12 +513,18 @@ export async function buildAssistantAddressOrchestrationRuntime(
const orchestrationContract = toRecordObject(orchestrationDecision.orchestrationContract);
const predecomposeContract = toRecordObject(addressPreDecompose.predecomposeContract);
const explicitPredecomposeOrganization = predecomposeOrganizationName(predecomposeContract, input.toNonEmptyString);
const discoveryFollowupContext = mergeOrganizationIntoDiscoveryFollowupContext(
const discoveryFollowupContextWithOrganization = mergeOrganizationIntoDiscoveryFollowupContext(
followupContext,
explicitPredecomposeOrganization
? null
: sessionOrganizationName(input.sessionOrganizationScope ?? null, input.toNonEmptyString)
);
const discoveryFollowupContext = mergeBusinessOverviewDateContextForCompactCashflow({
userMessage: input.userMessage,
followupContext: discoveryFollowupContextWithOrganization,
sessionItems: input.sessionItems,
toNonEmptyString: input.toNonEmptyString
});
const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(
input.userMessage,
addressInputMessage,
@ -384,7 +540,8 @@ export async function buildAssistantAddressOrchestrationRuntime(
effectiveMessage: addressInputMessage,
assistantTurnMeaning: toRecordObject(orchestrationContract?.assistant_turn_meaning),
predecomposeContract,
followupContext: discoveryFollowupContext
followupContext: discoveryFollowupContext,
knownOrganizations: sessionKnownOrganizations(input.sessionOrganizationScope ?? null)
})) as Record<string, unknown>;
} catch (error) {
mcpDiscoveryRuntimeEntryPointError = String(error instanceof Error ? error.message : error ?? "unknown_error").slice(0, 280);

View File

@ -34,6 +34,13 @@ function formatMissingAnchors(anchors: string[]): string {
}
function buildClarificationReply(binding: AssistantCapabilityRuntimeBindingContract): string {
if (binding.capability_contract_id === "inventory_inventory_margin_ranking_for_nomenclature") {
return [
"Для рейтинга прибыльности номенклатуры нужен период.",
"Могу посчитать по номенклатуре: выручку без НДС, себестоимость реализации, валовую прибыль и маржинальность.",
"Уточните период: месяц, квартал, год или весь доступный период."
].join("\n\n");
}
return [
"Нужно уточнение, чтобы не подставить неподтвержденный объект в расчет.",
`Не хватает: ${formatMissingAnchors(binding.missing_anchors)}.`,

View File

@ -161,6 +161,15 @@ function anchorSatisfied(requiredAnchor: string, providedAnchors: string[], debu
if (providedAnchors.includes(requiredAnchor)) {
return true;
}
if (requiredAnchor === "period") {
return (
(hasValue(filters?.period_from) && hasValue(filters?.period_to)) ||
hasValue(filters?.as_of_date) ||
providedAnchors.includes("period_from") ||
providedAnchors.includes("period_to") ||
providedAnchors.includes("as_of_date")
);
}
if (requiredAnchor === "item") {
return (
providedAnchors.includes("selected_object") ||

View File

@ -0,0 +1,278 @@
import type { AssistantMcpDiscoveryDataNeedGraphContract } from "./assistantMcpDiscoveryDataNeedGraph";
import type {
AssistantMcpDiscoveryPlanContract,
AssistantMcpDiscoveryPrimitive,
AssistantMcpDiscoveryTurnMeaningRef
} from "./assistantMcpDiscoveryPolicy";
export const ASSISTANT_EVIDENCE_PLANNER_SCHEMA_VERSION = "assistant_evidence_planner_v1" as const;
export type AssistantEvidencePlannerStatus = "ready_for_execution" | "needs_clarification" | "blocked";
export type AssistantEvidenceCoverageExpectation = "confirmed_coverage" | "bounded_inference" | "clarification";
export type AssistantEvidenceAnswerMode =
| "confirmed_business_answer"
| "bounded_business_inference"
| "clarification_required"
| "checked_sources_only";
export interface AssistantEvidenceDataNeedContract {
business_fact_family: string | null;
action_family: string | null;
aggregation_need: string | null;
comparison_need: string | null;
ranking_need: string | null;
time_scope_need: string | null;
proof_expectation: string | null;
subject_candidates: string[];
}
export interface AssistantEvidenceAxesContract {
required_axes: string[];
provided_axes: string[];
missing_axes: string[];
user_actionable_missing_axes: string[];
clarification_gaps: string[];
}
export interface AssistantEvidencePrimitivePlanContract {
selected_chain_id: string;
allowed_primitives: AssistantMcpDiscoveryPrimitive[];
rejected_primitives: string[];
execution_budget: AssistantMcpDiscoveryPlanContract["execution_budget"];
}
export interface AssistantEvidenceCoverageGateContract {
requires_evidence_gate: true;
expected_coverage: AssistantEvidenceCoverageExpectation;
answer_permission_if_satisfied: AssistantEvidenceAnswerMode;
answer_may_use_raw_model_claims: false;
}
export interface AssistantEvidenceAnswerContract {
answer_mode: AssistantEvidenceAnswerMode;
required_user_layers: string[];
forbidden_overclaim_flags: string[];
must_keep_internal_mechanics_hidden: true;
}
export interface AssistantEvidencePlannerContract {
schema_version: typeof ASSISTANT_EVIDENCE_PLANNER_SCHEMA_VERSION;
policy_owner: "assistantEvidencePlanner";
planner_status: AssistantEvidencePlannerStatus;
semantic_data_need: string | null;
selected_chain_id: string;
data_need: AssistantEvidenceDataNeedContract;
evidence_axes: AssistantEvidenceAxesContract;
primitive_plan: AssistantEvidencePrimitivePlanContract;
coverage_gate: AssistantEvidenceCoverageGateContract;
answer_contract: AssistantEvidenceAnswerContract;
reason_codes: string[];
}
export interface BuildAssistantEvidencePlannerInput {
selectedChainId: string;
plannerStatus: AssistantEvidencePlannerStatus;
semanticDataNeed?: string | null;
dataNeedGraph?: AssistantMcpDiscoveryDataNeedGraphContract | null;
discoveryPlan: AssistantMcpDiscoveryPlanContract;
additionalMissingAxes?: string[] | null;
}
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function normalizeReasonCode(value: string): string | null {
const normalized = value
.trim()
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized.length > 0 ? normalized.slice(0, 120) : null;
}
function pushReason(target: string[], value: string): void {
const normalized = normalizeReasonCode(value);
if (normalized && !target.includes(normalized)) {
target.push(normalized);
}
}
function uniqueStrings(values: unknown[]): string[] {
const result: string[] = [];
for (const value of values) {
const text = toNonEmptyString(value);
if (text && !result.includes(text)) {
result.push(text);
}
}
return result;
}
function providedAxesFromMeaning(meaning: AssistantMcpDiscoveryTurnMeaningRef | null): string[] {
const result: string[] = [];
if ((meaning?.explicit_entity_candidates?.length ?? 0) > 0) {
result.push("counterparty");
result.push("business_entity");
}
if (toNonEmptyString(meaning?.explicit_organization_scope)) {
result.push("organization");
}
if (toNonEmptyString(meaning?.explicit_date_scope)) {
result.push("period");
}
if (toNonEmptyString(meaning?.asked_aggregation_axis)) {
result.push("aggregate_axis");
}
if (toNonEmptyString(meaning?.metadata_scope_hint)) {
result.push("metadata_scope");
}
return uniqueStrings(result);
}
function missingAxes(requiredAxes: string[], providedAxes: string[]): string[] {
return requiredAxes.filter((axis) => !providedAxes.includes(axis));
}
const USER_ACTIONABLE_AXIS_SET = new Set([
"counterparty",
"business_entity",
"organization",
"period",
"as_of_date",
"item",
"supplier",
"buyer",
"warehouse",
"document",
"contract",
"metadata_scope",
"lane_family_choice"
]);
function userActionableMissingAxes(axes: string[]): string[] {
return axes.filter((axis) => USER_ACTIONABLE_AXIS_SET.has(axis));
}
function coverageExpectationFor(
graph: AssistantMcpDiscoveryDataNeedGraphContract | null
): AssistantEvidenceCoverageExpectation {
if (graph?.proof_expectation === "bounded_inference") {
return "bounded_inference";
}
if (graph?.proof_expectation === "clarification_required") {
return "clarification";
}
return "confirmed_coverage";
}
function answerModeFor(input: {
plannerStatus: AssistantEvidencePlannerStatus;
coverageExpectation: AssistantEvidenceCoverageExpectation;
}): AssistantEvidenceAnswerMode {
if (input.plannerStatus === "needs_clarification") {
return "clarification_required";
}
if (input.plannerStatus === "blocked") {
return "checked_sources_only";
}
if (input.coverageExpectation === "bounded_inference") {
return "bounded_business_inference";
}
if (input.coverageExpectation === "clarification") {
return "clarification_required";
}
return "confirmed_business_answer";
}
function requiredUserLayersFor(answerMode: AssistantEvidenceAnswerMode): string[] {
if (answerMode === "clarification_required") {
return ["clarifying_question", "why_needed", "available_calculation"];
}
if (answerMode === "bounded_business_inference") {
return ["business_conclusion", "key_figures", "evidence_boundary", "next_step"];
}
if (answerMode === "checked_sources_only") {
return ["checked_sources_boundary", "unknowns", "next_probe"];
}
return ["direct_business_answer", "key_figures", "evidence_boundary", "next_step"];
}
export function buildAssistantEvidencePlanner(
input: BuildAssistantEvidencePlannerInput
): AssistantEvidencePlannerContract {
const graph = input.dataNeedGraph ?? null;
const plan = input.discoveryPlan;
const turnMeaning = plan.turn_meaning_ref;
const requiredAxes = uniqueStrings(plan.required_axes);
const providedAxes = providedAxesFromMeaning(turnMeaning);
const graphClarificationGaps = uniqueStrings(graph?.clarification_gaps ?? []);
const additionalAxisGaps = uniqueStrings(input.additionalMissingAxes ?? []).filter(
(axis) => !providedAxes.includes(axis) && (requiredAxes.includes(axis) || USER_ACTIONABLE_AXIS_SET.has(axis)),
);
const axisGaps = uniqueStrings([...additionalAxisGaps, ...missingAxes(requiredAxes, providedAxes)]);
const actionableAxisGaps = userActionableMissingAxes(axisGaps);
const clarificationGaps = uniqueStrings([...graphClarificationGaps, ...axisGaps]);
const coverageExpectation = coverageExpectationFor(graph);
const answerMode = answerModeFor({
plannerStatus: input.plannerStatus,
coverageExpectation
});
const reasonCodes = uniqueStrings(plan.reason_codes);
pushReason(reasonCodes, "evidence_planner_contract_built");
if (graph) {
pushReason(reasonCodes, "evidence_planner_consumed_data_need_graph");
}
if (clarificationGaps.length > 0) {
pushReason(reasonCodes, "evidence_planner_has_missing_axes_or_gaps");
}
return {
schema_version: ASSISTANT_EVIDENCE_PLANNER_SCHEMA_VERSION,
policy_owner: "assistantEvidencePlanner",
planner_status: input.plannerStatus,
semantic_data_need: toNonEmptyString(input.semanticDataNeed),
selected_chain_id: input.selectedChainId,
data_need: {
business_fact_family: graph?.business_fact_family ?? null,
action_family: graph?.action_family ?? null,
aggregation_need: graph?.aggregation_need ?? null,
comparison_need: graph?.comparison_need ?? null,
ranking_need: graph?.ranking_need ?? null,
time_scope_need: graph?.time_scope_need ?? null,
proof_expectation: graph?.proof_expectation ?? null,
subject_candidates: uniqueStrings(graph?.subject_candidates ?? [])
},
evidence_axes: {
required_axes: requiredAxes,
provided_axes: providedAxes,
missing_axes: axisGaps,
user_actionable_missing_axes: actionableAxisGaps,
clarification_gaps: clarificationGaps
},
primitive_plan: {
selected_chain_id: input.selectedChainId,
allowed_primitives: plan.allowed_primitives,
rejected_primitives: plan.rejected_primitives,
execution_budget: plan.execution_budget
},
coverage_gate: {
requires_evidence_gate: true,
expected_coverage: coverageExpectation,
answer_permission_if_satisfied: answerMode,
answer_may_use_raw_model_claims: false
},
answer_contract: {
answer_mode: answerMode,
required_user_layers: requiredUserLayersFor(answerMode),
forbidden_overclaim_flags: uniqueStrings(graph?.forbidden_overclaim_flags ?? []),
must_keep_internal_mechanics_hidden: true
},
reason_codes: reasonCodes
};
}

View File

@ -1392,26 +1392,18 @@ function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutio
const organization = organizationScope ? ` по организации ${organizationScope}` : "";
const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : "";
const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне";
const movementLabel =
flow.value_flow_direction === "outgoing_supplier_payout"
? "исходящих платежей/списаний"
: "входящих денежных поступлений";
const totalLabel =
flow.value_flow_direction === "outgoing_supplier_payout"
? "сумма исходящих платежей/списаний составляет"
: "сумма входящих денежных поступлений составляет";
? "исходящие платежи/списания"
: "входящие денежные поступления";
const caveat =
flow.value_flow_direction === "outgoing_supplier_payout"
? "Это расчет по найденным строкам 1С, а не подтверждение полного объема платежей вне проверенного окна."
: "Это расчет по найденным строкам 1С, а не подтверждение полного объема поступлений вне проверенного окна.";
const dates =
flow.first_movement_date && flow.latest_movement_date
? ` Первая найденная дата движения: ${flow.first_movement_date}; последняя: ${flow.latest_movement_date}.`
: "";
? "Граница: это проверенный денежный срез по найденным списаниям в 1С, не внешний аудит всех платежей вне этого окна."
: "Граница: это проверенный денежный срез по найденным поступлениям в 1С, не внешний аудит всех поступлений вне этого окна.";
const limitCaveat = flow.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; полный запрошенный период может быть покрыт не полностью."
? " Проверка уперлась в лимит строк, поэтому полный период может быть покрыт не полностью."
: "";
return `По найденным строкам ${movementLabel} в 1С${counterparty}${period} ${totalLabel} ${flow.total_amount_human_ru} Учтено строк с суммой: ${flow.rows_with_amount} из ${flow.rows_matched}.${dates}${limitCaveat} ${caveat}`;
return `${totalLabel}${counterparty || organization}${period}: ${flow.total_amount_human_ru}.${limitCaveat} ${caveat}`;
}
function derivedValueFlowMonthlyLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
@ -2109,7 +2101,7 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
: pilot.derived_ranked_value_flow && derivedValueLine
? [derivedValueLine]
: derivedValueLine
? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines]
? [derivedValueLine, ...monthlyConfirmedLines]
: valueFlowZeroResultConfirmedLine(pilot)
? [valueFlowZeroResultConfirmedLine(pilot)!]
: derivedEntityResolutionLine

View File

@ -176,6 +176,18 @@ function hasBusinessOverviewDirectMoneyAnswerHint(input: {
return true;
}
const text = input.rawUtterance;
if (
/(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(
text
)
) {
return true;
}
if (
/(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u0434\u0435\u043d\p{L}{0,20}\s+\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\u043b\p{L}*[\s\S]{0,80}\u0443\u0448\u043b\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u0432\u0445\u043e\u0434\u044f\u0449\p{L}*[\s\S]{0,80}\u0438\u0441\u0445\u043e\u0434\u044f\u0449\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e)/iu.test(text)
) {
return true;
}
return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|how\s+much)[\s\S]{0,120}(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u0435\u043d\p{L}*|\u043f\u043e\u043b\u0443\u0447|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*)|(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447)[\s\S]{0,120}(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u0432\u0441\u0435\u0433\u043e|\u0432\u043e\u043e\u0431\u0449\u0435|(?:19|20)\d{2}|all\s+time)|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|which|what)[\s\S]{0,80}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,80}(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,40}(?:\u0433\u043e\u0434|year)/iu.test(
text
);
@ -293,6 +305,12 @@ function rankingNeedFromRawUtterance(value: string): string | null {
if (!text) {
return null;
}
if (/\u0438\u0441\u043a\u043b\u044e\u0447[\p{L}\p{N}_]*\s+\u0442\u043e\u043f/iu.test(text)) {
return null;
}
if (/(?:\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u043d\u0435\s+\u0442\u043e\u043f\b|\u0438\u0441\u043a\u043b\u044e\u0447\w*\s+\u0442\u043e\u043f|\u0431\u0435\u0437\s+\u0440\u0435\u0439\u0442\u0438\u043d\u0433\u0430\b|без\s+СРѕРї(?:РѕРІ|Р°)?\b|РЅРµ\s+СРѕРї\b|исклюС\S*\s+СРѕРї|без\s+СЂРµРСРёРЅРіР°\b)/iu.test(text)) {
return null;
}
if (/(?:\u0442\u043e\u043f[-\s]?\d*|\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)|\u0431\u043e\u043b\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u0431\u043e\u043b[\u0435\u0451]\u0435|\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c|\u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448|\u043b\u0443\u0447\u0448\u0438\u0439)/iu.test(text)) {
return "top_desc";
}
@ -324,6 +342,16 @@ function rankingNeedFromRawUtterance(value: string): string | null {
return null;
}
function suppressRankingNeedFromRawUtterance(value: string): boolean {
const text = lower(value);
if (!text) {
return false;
}
return /(?:\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u043d\u0435\s+\u0442\u043e\u043f\b|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440\b|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0438\u0441\u043a\u043b\u044e\u0447\w*\s+\u0442\u043e\u043f|\u0431\u0435\u0437\s+\u0440\u0435\u0439\u0442\u0438\u043d\u0433\u0430\b|без\s+С‚РСР С(?:Р СР Р|Р В°)?\b|Р РР Вµ\s+С‚РСР С\b|Р РР Вµ\s+Р СР В±Р В·Р СРЎР\b|Р СРЎРР ССЃС‚РС\s+Р ТРµРР\p{L}+|Р СРЎРѓР СР»СРС‡\S*\s+С‚РСР С|без\s+РЎРРµРвС‚РСР РР СР В°\b)/iu.test(
text
);
}
function proofExpectationFor(input: {
family: string | null;
clarificationGaps: string[];
@ -497,6 +525,7 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
const action = lower(turnMeaning?.asked_action_family);
const unsupported = lower(turnMeaning?.unsupported_but_understood_family);
const rawUtterance = lower(input.rawUtterance);
const rawQuestionSignal = lower([input.rawUtterance, turnMeaning?.raw_message, turnMeaning?.effective_message].join(" "));
const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis);
const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need);
const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope);
@ -514,16 +543,22 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
});
const aggregationNeed = aggregationNeedFor(aggregationAxis);
const comparisonNeed = comparisonNeedFor(action);
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed;
const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance);
const subjectScopedBidirectionalAllTime =
businessFactFamily === "value_flow" &&
comparisonNeed === "incoming_vs_outgoing" &&
subjectCandidates.length > 0 &&
!explicitDateScope;
const suppressRankingNeed =
suppressRankingNeedFromRawUtterance(rawQuestionSignal) ||
/(?:\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?|\u043d\u0435\s+\u0442\u043e\u043f|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0438\u0441\u043a\u043b\u044e\u0447\w*\s+\u0442\u043e\u043f|\u0431\u0435\u0437\s+\u0440\u0435\u0439\u0442\u0438\u043d\u0433\u0430)/iu.test(
rawQuestionSignal
);
const rawRankingNeed = rankingNeedFromRawUtterance(rawQuestionSignal);
const rankingNeed = suppressRankingNeed ? null : rawRankingNeed ?? seededRankingNeed;
const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({
family: businessFactFamily,
rawUtterance,
rawUtterance: rawQuestionSignal,
rankingNeed
});
const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action);

View File

@ -1,13 +1,20 @@
import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint";
import type { AssistantMcpRouteCandidateContract } from "./assistantMcpDiscoveryRuntimeBridge";
import type { AssistantMcpDiscoveryExecutionHandoffContract } from "./assistantMcpDiscoveryExecutionHandoff";
import type { AssistantEvidencePlannerContract } from "./assistantEvidencePlanner";
export interface AssistantMcpDiscoveryDebugAttachmentFields {
assistant_mcp_discovery_entry_point_v1: AssistantMcpDiscoveryRuntimeEntryPointContract | null;
mcp_discovery_entry_status: string | null;
mcp_discovery_attempted: boolean;
mcp_discovery_hot_runtime_wired: false;
mcp_discovery_hot_runtime_wired: boolean;
mcp_discovery_bridge_status: string | null;
mcp_discovery_selected_chain_id: string | null;
mcp_discovery_evidence_plan_v1: AssistantEvidencePlannerContract | null;
mcp_discovery_evidence_plan_status: string | null;
mcp_discovery_evidence_plan_answer_mode: string | null;
mcp_discovery_evidence_plan_missing_axes: string[];
mcp_discovery_evidence_plan_expected_coverage: string | null;
mcp_discovery_catalog_chain_template_matches: string[];
mcp_discovery_catalog_chain_alignment_status: string | null;
mcp_discovery_catalog_chain_top_match: string | null;
@ -17,11 +24,18 @@ export interface AssistantMcpDiscoveryDebugAttachmentFields {
mcp_discovery_route_candidate_fact_family: string | null;
mcp_discovery_route_candidate_action_family: string | null;
mcp_discovery_route_candidate_proof_expectation: string | null;
mcp_discovery_route_candidate_evidence_plan_status: string | null;
mcp_discovery_route_candidate_evidence_answer_mode: string | null;
mcp_discovery_route_candidate_evidence_expected_coverage: string | null;
mcp_discovery_route_candidate_missing_axes: string[];
mcp_discovery_route_candidate_provided_axes: string[];
mcp_discovery_route_candidate_executable_now: boolean;
mcp_discovery_route_candidate_enablement_reason: string | null;
mcp_discovery_route_candidate_next_action: string | null;
mcp_discovery_execution_handoff_v1: AssistantMcpDiscoveryExecutionHandoffContract | null;
mcp_discovery_execution_handoff_status: string | null;
mcp_discovery_execution_handoff_allowed_hot_chain: boolean;
mcp_discovery_execution_handoff_can_use_guarded_response: boolean;
mcp_discovery_answer_mode: string | null;
mcp_discovery_business_fact_answer_allowed: boolean;
mcp_discovery_user_facing_response_allowed: boolean;
@ -78,6 +92,19 @@ function isRouteCandidateContract(value: unknown): value is AssistantMcpRouteCan
);
}
function isEvidencePlannerContract(value: unknown): value is AssistantEvidencePlannerContract {
const record = toRecordObject(value);
return record?.schema_version === "assistant_evidence_planner_v1" && record?.policy_owner === "assistantEvidencePlanner";
}
function isExecutionHandoffContract(value: unknown): value is AssistantMcpDiscoveryExecutionHandoffContract {
const record = toRecordObject(value);
return (
record?.schema_version === "assistant_mcp_discovery_execution_handoff_v1" &&
record?.policy_owner === "assistantMcpDiscoveryExecutionHandoff"
);
}
function resolveEntryPoint(input: AttachAssistantMcpDiscoveryDebugInput): AssistantMcpDiscoveryRuntimeEntryPointContract | null {
if (isMcpDiscoveryEntryPointContract(input.entryPoint)) {
return input.entryPoint;
@ -95,17 +122,31 @@ export function buildAssistantMcpDiscoveryDebugAttachmentFields(
const entryPoint = resolveEntryPoint(input);
const bridge = toRecordObject(entryPoint?.bridge);
const planner = toRecordObject(bridge?.planner);
const evidencePlan = isEvidencePlannerContract(planner?.evidence_plan) ? planner.evidence_plan : null;
const evidencePlanAxes = toRecordObject(evidencePlan?.evidence_axes);
const evidencePlanCoverageGate = toRecordObject(evidencePlan?.coverage_gate);
const evidencePlanAnswerContract = toRecordObject(evidencePlan?.answer_contract);
const chainAlignment = toRecordObject(planner?.catalog_chain_template_alignment);
const routeCandidate = isRouteCandidateContract(bridge?.route_candidate) ? bridge.route_candidate : null;
const executionHandoff = isExecutionHandoffContract(bridge?.execution_handoff) ? bridge.execution_handoff : null;
const answerDraft = toRecordObject(bridge?.answer_draft);
const hotRuntimeWired =
entryPoint?.hot_runtime_wired === true ||
bridge?.hot_runtime_wired === true ||
executionHandoff?.can_use_guarded_response === true;
return {
assistant_mcp_discovery_entry_point_v1: entryPoint,
mcp_discovery_entry_status: toNonEmptyString(entryPoint?.entry_status),
mcp_discovery_attempted: Boolean(entryPoint?.discovery_attempted),
mcp_discovery_hot_runtime_wired: false,
mcp_discovery_hot_runtime_wired: hotRuntimeWired,
mcp_discovery_bridge_status: toNonEmptyString(bridge?.bridge_status),
mcp_discovery_selected_chain_id: toNonEmptyString(planner?.selected_chain_id),
mcp_discovery_evidence_plan_v1: evidencePlan,
mcp_discovery_evidence_plan_status: toNonEmptyString(evidencePlan?.planner_status),
mcp_discovery_evidence_plan_answer_mode: toNonEmptyString(evidencePlanAnswerContract?.answer_mode),
mcp_discovery_evidence_plan_missing_axes: toStringArray(evidencePlanAxes?.missing_axes),
mcp_discovery_evidence_plan_expected_coverage: toNonEmptyString(evidencePlanCoverageGate?.expected_coverage),
mcp_discovery_catalog_chain_template_matches: toStringArray(planner?.catalog_chain_template_matches),
mcp_discovery_catalog_chain_alignment_status: toNonEmptyString(chainAlignment?.alignment_status),
mcp_discovery_catalog_chain_top_match: toNonEmptyString(chainAlignment?.top_chain_template_match),
@ -115,11 +156,18 @@ export function buildAssistantMcpDiscoveryDebugAttachmentFields(
mcp_discovery_route_candidate_fact_family: toNonEmptyString(routeCandidate?.business_fact_family),
mcp_discovery_route_candidate_action_family: toNonEmptyString(routeCandidate?.action_family),
mcp_discovery_route_candidate_proof_expectation: toNonEmptyString(routeCandidate?.proof_expectation),
mcp_discovery_route_candidate_evidence_plan_status: toNonEmptyString(routeCandidate?.evidence_plan_status),
mcp_discovery_route_candidate_evidence_answer_mode: toNonEmptyString(routeCandidate?.evidence_answer_mode),
mcp_discovery_route_candidate_evidence_expected_coverage: toNonEmptyString(routeCandidate?.evidence_expected_coverage),
mcp_discovery_route_candidate_missing_axes: toStringArray(routeCandidate?.missing_axes),
mcp_discovery_route_candidate_provided_axes: toStringArray(routeCandidate?.provided_axes),
mcp_discovery_route_candidate_executable_now: routeCandidate?.executable_now === true,
mcp_discovery_route_candidate_enablement_reason: toNonEmptyString(routeCandidate?.enablement_reason),
mcp_discovery_route_candidate_next_action: toNonEmptyString(routeCandidate?.recommended_next_action),
mcp_discovery_execution_handoff_v1: executionHandoff,
mcp_discovery_execution_handoff_status: toNonEmptyString(executionHandoff?.handoff_status),
mcp_discovery_execution_handoff_allowed_hot_chain: executionHandoff?.allowed_hot_chain === true,
mcp_discovery_execution_handoff_can_use_guarded_response: executionHandoff?.can_use_guarded_response === true,
mcp_discovery_answer_mode: toNonEmptyString(answerDraft?.answer_mode),
mcp_discovery_business_fact_answer_allowed: bridge?.business_fact_answer_allowed === true,
mcp_discovery_user_facing_response_allowed: bridge?.user_facing_response_allowed === true,

View File

@ -0,0 +1,134 @@
import type { AssistantMcpDiscoveryAnswerDraftContract } from "./assistantMcpDiscoveryAnswerAdapter";
import type { AssistantMcpDiscoveryPilotExecutionContract } from "./assistantMcpDiscoveryPilotExecutor";
import type { AssistantMcpDiscoveryChainId } from "./assistantMcpDiscoveryPlanner";
import type {
AssistantMcpDiscoveryRuntimeBridgeStatus,
AssistantMcpRouteCandidateContract
} from "./assistantMcpDiscoveryRuntimeBridge";
export const ASSISTANT_MCP_DISCOVERY_EXECUTION_HANDOFF_SCHEMA_VERSION =
"assistant_mcp_discovery_execution_handoff_v1" as const;
export type AssistantMcpDiscoveryExecutionHandoffStatus =
| "ready_for_guarded_response"
| "awaiting_user_scope"
| "checked_sources_only"
| "not_enabled_for_chain"
| "blocked";
export interface AssistantMcpDiscoveryExecutionHandoffInput {
bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus;
routeCandidate: AssistantMcpRouteCandidateContract;
pilot: AssistantMcpDiscoveryPilotExecutionContract;
answerDraft: AssistantMcpDiscoveryAnswerDraftContract;
businessFactAnswerAllowed: boolean;
userFacingResponseAllowed: boolean;
}
export interface AssistantMcpDiscoveryExecutionHandoffContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_EXECUTION_HANDOFF_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryExecutionHandoff";
handoff_status: AssistantMcpDiscoveryExecutionHandoffStatus;
selected_chain_id: AssistantMcpDiscoveryChainId;
route_candidate_status: AssistantMcpRouteCandidateContract["candidate_status"];
evidence_answer_mode: string | null;
evidence_expected_coverage: string | null;
pilot_status: AssistantMcpDiscoveryPilotExecutionContract["pilot_status"];
answer_mode: AssistantMcpDiscoveryAnswerDraftContract["answer_mode"];
mcp_execution_performed: boolean;
allowed_hot_chain: boolean;
can_use_guarded_response: boolean;
must_keep_internal_mechanics_hidden: true;
reason_codes: string[];
}
const HOT_HANDOFF_CHAIN_ALLOWLIST: AssistantMcpDiscoveryChainId[] = ["value_flow"];
function uniqueStrings(values: string[]): string[] {
const result: string[] = [];
for (const value of values) {
const text = String(value ?? "").trim();
if (text && !result.includes(text)) {
result.push(text);
}
}
return result;
}
function handoffStatusFor(
input: AssistantMcpDiscoveryExecutionHandoffInput,
allowedHotChain: boolean
): AssistantMcpDiscoveryExecutionHandoffStatus {
if (input.bridgeStatus === "needs_clarification" || input.routeCandidate.candidate_status === "needs_user_scope") {
return "awaiting_user_scope";
}
if (input.bridgeStatus === "checked_sources_only") {
return "checked_sources_only";
}
if (
input.bridgeStatus === "blocked" ||
input.bridgeStatus === "unsupported" ||
input.routeCandidate.candidate_status === "blocked" ||
input.routeCandidate.candidate_status === "needs_route_enablement"
) {
return "blocked";
}
if (!allowedHotChain) {
return "not_enabled_for_chain";
}
return "ready_for_guarded_response";
}
export function buildAssistantMcpDiscoveryExecutionHandoff(
input: AssistantMcpDiscoveryExecutionHandoffInput
): AssistantMcpDiscoveryExecutionHandoffContract {
const allowedHotChain = HOT_HANDOFF_CHAIN_ALLOWLIST.includes(input.routeCandidate.selected_chain_id);
const baseStatus = handoffStatusFor(input, allowedHotChain);
const readinessChecksPassed =
baseStatus === "ready_for_guarded_response" &&
input.bridgeStatus === "answer_draft_ready" &&
input.routeCandidate.candidate_status === "ready_for_reviewed_execution" &&
input.routeCandidate.executable_now === true &&
input.pilot.pilot_status === "executed" &&
input.pilot.mcp_execution_performed === true &&
input.businessFactAnswerAllowed === true &&
input.userFacingResponseAllowed === true &&
input.answerDraft.internal_mechanics_allowed === false;
const handoffStatus = readinessChecksPassed ? "ready_for_guarded_response" : baseStatus;
const reasonCodes = uniqueStrings([
`execution_handoff_status_${handoffStatus}`,
allowedHotChain ? "execution_handoff_chain_allowlisted" : "execution_handoff_chain_not_allowlisted",
input.routeCandidate.executable_now
? "execution_handoff_route_candidate_executable"
: "execution_handoff_route_candidate_not_executable",
input.pilot.mcp_execution_performed
? "execution_handoff_mcp_execution_performed"
: "execution_handoff_mcp_execution_not_performed",
input.businessFactAnswerAllowed
? "execution_handoff_business_fact_allowed"
: "execution_handoff_business_fact_not_allowed",
input.userFacingResponseAllowed
? "execution_handoff_user_facing_allowed"
: "execution_handoff_user_facing_not_allowed",
readinessChecksPassed
? "execution_handoff_guarded_response_ready"
: "execution_handoff_guarded_response_not_ready"
]);
return {
schema_version: ASSISTANT_MCP_DISCOVERY_EXECUTION_HANDOFF_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryExecutionHandoff",
handoff_status: handoffStatus,
selected_chain_id: input.routeCandidate.selected_chain_id,
route_candidate_status: input.routeCandidate.candidate_status,
evidence_answer_mode: input.routeCandidate.evidence_answer_mode,
evidence_expected_coverage: input.routeCandidate.evidence_expected_coverage,
pilot_status: input.pilot.pilot_status,
answer_mode: input.answerDraft.answer_mode,
mcp_execution_performed: input.pilot.mcp_execution_performed,
allowed_hot_chain: allowedHotChain,
can_use_guarded_response: readinessChecksPassed,
must_keep_internal_mechanics_hidden: true,
reason_codes: reasonCodes
};
}

View File

@ -717,15 +717,59 @@ function aggregationAxisForPlanner(
return axis === "month" ? "month" : null;
}
function cleanExplicitEntityCandidate(value: string): string | null {
let text = value.replace(/\s+/g, " ").trim();
if (!text) {
return null;
}
text = text
.replace(
/^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu,
""
)
.replace(/^(?:по\s+)?(?:контрагент(?:у|ом)?|поставщик(?:у|ом)?|клиент(?:у|ом)?)\s+/iu, "")
.replace(/\s+за\s+\d{4}(?:\s+год)?$/iu, "")
.trim();
return text || null;
}
function scoreExplicitEntityCandidate(raw: string, cleaned: string, index: number): number {
let score = 100 - index;
if (raw.trim() === cleaned) {
score += 20;
}
if (/^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu.test(raw)) {
score -= 40;
}
if (/[А-ЯЁA-Z]/u.test(cleaned)) {
score += 8;
}
if (/(?:^|\s)(?:ООО|АО|ПАО|ИП|Группа|Комитет|Департамент)(?:\s|$)/u.test(cleaned)) {
score += 6;
}
score -= Math.max(0, cleaned.length - 60);
return score;
}
function firstEntityCandidate(planner: AssistantMcpDiscoveryPlannerContract): string | null {
const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? [];
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
if (text) {
return text;
let best: { text: string; score: number } | null = null;
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const raw = toNonEmptyString(candidate);
if (!raw) {
continue;
}
const cleaned = cleanExplicitEntityCandidate(raw);
if (!cleaned) {
continue;
}
const score = scoreExplicitEntityCandidate(raw, cleaned, index);
if (!best || score > best.score) {
best = { text: cleaned, score };
}
}
return null;
return best?.text ?? null;
}
function dateScopeToFilters(dateScope: string | null): Pick<AddressFilterSet, "period_from" | "period_to"> {

View File

@ -15,6 +15,10 @@ import {
type AssistantMcpCatalogPlanReview
} from "./assistantMcpCatalogIndex";
import type { AssistantMcpDiscoveryDataNeedGraphContract } from "./assistantMcpDiscoveryDataNeedGraph";
import {
buildAssistantEvidencePlanner,
type AssistantEvidencePlannerContract
} from "./assistantEvidencePlanner";
export const ASSISTANT_MCP_DISCOVERY_PLANNER_SCHEMA_VERSION = "assistant_mcp_discovery_planner_v1" as const;
@ -91,6 +95,7 @@ export interface AssistantMcpDiscoveryPlannerContract {
catalog_chain_template_alignment: AssistantMcpDiscoveryCatalogChainTemplateAlignment;
proposed_primitives: AssistantMcpDiscoveryPrimitive[];
required_axes: string[];
evidence_plan: AssistantEvidencePlannerContract;
discovery_plan: AssistantMcpDiscoveryPlanContract;
catalog_review: AssistantMcpCatalogPlanReview;
reason_codes: string[];
@ -153,6 +158,17 @@ function pushAllUnique(target: string[], values: string[]): void {
}
}
function uniqueStringList(values: unknown[]): string[] {
const result: string[] = [];
for (const value of values) {
const text = toNonEmptyString(value);
if (text) {
pushUnique(result, text);
}
}
return result;
}
function pushCatalogChainTemplateAlignmentReasons(
target: string[],
alignment: AssistantMcpDiscoveryCatalogChainTemplateAlignment
@ -1355,6 +1371,69 @@ function statusFrom(
return "ready_for_execution";
}
function providedAxesFromPlanTurnMeaning(plan: AssistantMcpDiscoveryPlanContract): string[] {
const result: string[] = [];
const meaning = plan.turn_meaning_ref;
if ((meaning?.explicit_entity_candidates?.length ?? 0) > 0) {
pushUnique(result, "counterparty");
pushUnique(result, "business_entity");
}
if (toNonEmptyString(meaning?.explicit_organization_scope)) {
pushUnique(result, "organization");
}
if (toNonEmptyString(meaning?.explicit_date_scope)) {
pushUnique(result, "period");
}
if (toNonEmptyString(meaning?.asked_aggregation_axis)) {
pushUnique(result, "aggregate_axis");
}
if (toNonEmptyString(meaning?.metadata_scope_hint)) {
pushUnique(result, "metadata_scope");
}
return result;
}
function preferredClarificationAxesForRecipe(input: {
recipe: PlannerRecipe;
dataNeedGraph: AssistantMcpDiscoveryDataNeedGraphContract | null;
}): string[] {
if (
input.recipe.chainId === "value_flow_ranking" ||
input.recipe.chainId === "value_flow_comparison" ||
input.recipe.chainId === "business_overview"
) {
return ["organization"];
}
if (input.dataNeedGraph?.business_fact_family === "value_flow" && !hasSubjectCandidates(input.dataNeedGraph)) {
return ["organization"];
}
return [];
}
function missingAxesFromCatalogReview(
review: AssistantMcpCatalogPlanReview,
input: {
providedAxes: string[];
preferredAxes: string[];
}
): string[] {
const result: string[] = [];
for (const options of Object.values(review.missing_axes_by_primitive)) {
const candidates = options
.map((option) => uniqueStringList(option).filter((axis) => !input.providedAxes.includes(axis)))
.filter((option) => option.length > 0);
const preferredCandidate = candidates.find((option) => option.some((axis) => input.preferredAxes.includes(axis)));
const candidate = preferredCandidate ?? candidates.sort((left, right) => left.length - right.length)[0];
if (!candidate) {
continue;
}
for (const axis of candidate) {
pushUnique(result, axis);
}
}
return result;
}
export function planAssistantMcpDiscovery(
input: AssistantMcpDiscoveryPlannerInput
): AssistantMcpDiscoveryPlannerContract {
@ -1422,6 +1501,17 @@ export function planAssistantMcpDiscovery(
} else {
pushReason(reasonCodes, "planner_needs_more_user_or_scope_context");
}
const evidencePlan = buildAssistantEvidencePlanner({
selectedChainId: recipe.chainId,
plannerStatus,
semanticDataNeed,
dataNeedGraph,
discoveryPlan: plan,
additionalMissingAxes: missingAxesFromCatalogReview(adjustedReview, {
providedAxes: providedAxesFromPlanTurnMeaning(plan),
preferredAxes: preferredClarificationAxesForRecipe({ recipe, dataNeedGraph })
})
});
return {
schema_version: ASSISTANT_MCP_DISCOVERY_PLANNER_SCHEMA_VERSION,
@ -1436,6 +1526,7 @@ export function planAssistantMcpDiscovery(
catalog_chain_template_alignment: catalogChainTemplateAlignment,
proposed_primitives: recipe.primitives,
required_axes: recipe.axes,
evidence_plan: evidencePlan,
discovery_plan: plan,
catalog_review: adjustedReview,
reason_codes: reasonCodes

View File

@ -16,7 +16,7 @@ export interface AssistantMcpDiscoveryResponseCandidateContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryResponseCandidate";
candidate_status: AssistantMcpDiscoveryResponseCandidateStatus;
hot_runtime_wired: false;
hot_runtime_wired: boolean;
reply_type: "partial_coverage" | "clarification_required" | "no_grounded_answer";
reply_text: string | null;
eligible_for_future_hot_runtime: boolean;
@ -60,6 +60,65 @@ function requestsFinancialCounterpartyBoundary(turnMeaning: Record<string, unkno
);
}
function requestsCompactCashflowAnswer(
turnMeaning: Record<string, unknown> | null,
graph: Record<string, unknown> | null
): boolean {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
turnMeaning?.effective_message,
graph?.source_message,
graph?.question
].join(" "));
if (!text) {
return false;
}
if (
/(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(
text
)
) {
return true;
}
return /(?:\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}+|\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433|\u0434\u0435\u043d\p{L}{0,20}\s+\u043d\u0435\u0442\u0442\u043e|\u043f\u043e\u043b\u0443\u0447\p{L}*[\s\S]{0,80}\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\u043b\p{L}*[\s\S]{0,80}\u0443\u0448\u043b\p{L}*[\s\S]{0,80}\u043d\u0435\u0442\u0442\u043e|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*)[\s\S]{0,120}(?:\u0434\u0435\u043d\p{L}*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u043e\u043b\u0443\u0447|\u043f\u0440\u0438\u0448\u043b))/iu.test(
text
);
}
function requestsCashflowPolarityAnswer(
turnMeaning: Record<string, unknown> | null,
graph: Record<string, unknown> | null
): boolean {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
turnMeaning?.effective_message,
graph?.source_message,
graph?.question
].join(" "));
return /(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(
text
);
}
function requestsDirectAccountingProfitAnswer(
turnMeaning: Record<string, unknown> | null,
graph: Record<string, unknown> | null
): boolean {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
turnMeaning?.effective_message,
graph?.source_message,
graph?.question
].join(" "));
if (!/(?:чист\p{L}{0,8}\s+прибыл|финрезультат|финансов\p{L}{0,12}\s+результат)/iu.test(text)) {
return false;
}
if (/(?:^|\s)(?:а\s+)?это\s+чист\p{L}{0,8}\s+прибыл|это\s+прибыл\p{L}{0,4}\s+или\s+нет/iu.test(text)) {
return false;
}
return /(?:какая|какой|сколько|посчитай|рассчитай|покажи|дай|определить|определи|найди|итог|за\s+\d{4})/iu.test(text);
}
function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
@ -262,7 +321,7 @@ function localizeLine(value: string): string {
return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С.";
}
if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) {
return "Нетто денежного потока рассчитано только как входящие подтвержденные строки 1С минус исходящие подтвержденные строки 1С.";
return "Нетто денежного потока рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи в 1С.";
}
if (/^Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows$/i.test(value)) {
return "Помесячная нетто-раскладка сгруппирована только по подтвержденным входящим и исходящим строкам 1С.";
@ -472,9 +531,9 @@ function businessOverviewCoverageLimitLine(overview: Record<string, unknown>): s
limited.push("исходящие");
}
const continuation =
"Если нужен полный сквозной ответ, безопасный следующий шаг — выбрать конкретный год или квартал для дозапроса: тогда широкий срез можно собрать частями без выдачи непроверенного итога.";
"Для полного сквозного итога лучше разбить проверку по годам или кварталам.";
return limited.length > 0
? `Важно: по направлению ${limited.join(" и ")} проверка достигла лимита строк; это расширенный проверенный срез найденных строк, но не гарантия полного бухгалтерского оборота без отдельной полной выгрузки. ${continuation}`
? `Ограничение: широкий денежный срез по направлению ${limited.join(" и ")} не считаю полным бухгалтерским оборотом без отдельной полной выгрузки. ${continuation}`
: null;
}
@ -602,41 +661,16 @@ function businessOverviewInventoryLine(overview: Record<string, unknown>): strin
}
const amount = moneyText(inventory.total_amount_human_ru);
const rows = Number(inventory.rows_matched);
const quantity = Number(inventory.total_quantity);
if (!amount && !Number.isFinite(rows)) {
return null;
}
const pieces = [
Number.isFinite(rows) ? `${rows} позиций` : null,
amount ? `на ${sentenceAmount(amount) ?? amount}` : null,
Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null
amount ? `на ${sentenceAmount(amount) ?? amount}` : null
].filter((item): item is string => Boolean(item));
return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null;
}
function rowCountText(value: unknown): string | null {
const count = Number(value);
return Number.isFinite(count) ? String(count) : null;
}
function sideRowsText(side: Record<string, unknown> | null): string | null {
const rowsWithAmount = rowCountText(side?.rows_with_amount);
const rowsMatched = rowCountText(side?.rows_matched);
if (rowsWithAmount && rowsMatched) {
return `${rowsWithAmount} из ${rowsMatched}`;
}
return rowsWithAmount ?? rowsMatched;
}
function sideDateText(side: Record<string, unknown> | null): string | null {
const first = toNonEmptyString(side?.first_movement_date);
const latest = toNonEmptyString(side?.latest_movement_date);
if (first && latest) {
return first === latest ? `дата ${first}` : `даты ${first}..${latest}`;
}
return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null;
}
function bidirectionalNetLabel(direction: unknown): string {
if (direction === "net_outgoing") {
return "нетто в сторону контрагента";
@ -678,29 +712,25 @@ function buildCompactBidirectionalValueFlowReply(
: "по выбранному контуру";
const period = toNonEmptyString(flow.period_scope);
const periodText = period ? ` за период ${period}` : " в проверенном окне";
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
const netLabel = bidirectionalNetLabel(flow.net_direction);
const lines = [
`Коротко: ${subjectLead}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
];
const basis: string[] = [];
if (incomingRows) {
basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
if (incomingAmount) {
basis.push("входящие платежи");
}
if (outgoingRows) {
basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
if (outgoingAmount) {
basis.push("исходящие платежи");
}
if (basis.length > 0) {
lines.push(`Основа: ${basis.join("; ")}.`);
lines.push(`Основа: проверенные ${basis.join(" и ")} по этому контрагенту в 1С.`);
}
if (flow.coverage_limited_by_probe_limit === true) {
lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
}
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
lines.push("Метод: нетто рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи; это не полное бухгалтерское сальдо вне проверенного окна.");
const fallbackNextStep = toNonEmptyString(draft.next_step_line);
if (fallbackNextStep) {
@ -718,6 +748,21 @@ function compactComparable(value: string | null): string {
.trim();
}
function businessOverviewOrganizationScopeLabel(value: unknown): string | null {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const comparable = compactComparable(text);
if (
/^(?:с|без)\s+разбивк/.test(comparable) ||
/\b(?:входящ|исходящ|нетто|топ|контрагент|платеж|поступлен)\b/.test(comparable)
) {
return null;
}
return text;
}
function businessOverviewSeparateSubjectLabel(
graph: Record<string, unknown> | null,
turnMeaning: Record<string, unknown> | null,
@ -785,21 +830,17 @@ function buildPreviousCounterpartyValueFlowSummary(
`; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` +
`${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`;
const basis: string[] = [];
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
if (incomingRows) {
basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
if (incomingAmount) {
basis.push("входящие платежи");
}
if (outgoingRows) {
basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
if (outgoingAmount) {
basis.push("исходящие платежи");
}
const documents = previousDocumentSummaryLine(documentBundle, counterparty);
if (documents) {
basis.push(documents);
}
const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : "";
const basisText = basis.length > 0 ? ` Основа: проверенные ${basis.join(" и ")}.` : "";
return {
lead,
line:
@ -835,7 +876,7 @@ function buildCompactBusinessOverviewReply(
const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто";
const period = businessOverviewPeriodText(overview);
const limitLine = businessOverviewCoverageLimitLine(overview);
const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
const organizationScope = businessOverviewOrganizationScopeLabel(turnMeaning?.explicit_organization_scope);
const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope);
const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary(
toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle),
@ -890,6 +931,29 @@ function buildCompactBusinessOverviewReply(
actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary";
const inventoryReserveBoundary =
actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary";
const compactCashflowRequested = directMoneyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph);
const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph);
const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph);
if (compactCashflowRequested && !rankingNeed && (incomingAmount || outgoingAmount || netAmount)) {
const netDisplay = sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431.";
const signedNetDisplay =
cashflowPolarityRequested && netDisplay && !String(netDisplay).trim().startsWith("-")
? `+${netDisplay}`
: netDisplay;
const polarityLead = cashflowPolarityRequested
? `\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c \u043f\u043b\u044e\u0441. `
: "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: ";
lines.push(
`${polarityLead}${organizationPrefix}${period} \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${signedNetDisplay}.`
);
lines.push(
cashflowPolarityRequested
? "\u042d\u0442\u043e \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c 1\u0421, \u043d\u0435 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442."
: "\u042d\u0442\u043e \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u043f\u043e\u0442\u043e\u043a \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c 1\u0421, \u043d\u0435 \u0447\u0438\u0441\u0442\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438 \u043d\u0435 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442."
);
return joinBusinessReplyLines(lines);
}
if (profitMarginBoundary) {
const accountingFinancialResult = toRecordObject(overview.accounting_financial_result);
@ -914,10 +978,14 @@ function buildCompactBusinessOverviewReply(
: amount
: "сумма не распознана";
lines.push(
`Коротко: нет, денежное операционное нетто не стоит считать чистой прибылью. Отдельно по закрытию счетов 90/91/99 в 1С за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к подтвержденной выручке ${marginPct}` : "; маржа к подтвержденной выручке не рассчитана"}.`
directAccountingProfitRequested
? `Коротко: за ${periodScope} по закрытию счетов 90/91/99 в 1С подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к подтвержденной выручке ${marginPct}` : "; маржа к подтвержденной выручке не рассчитана"}.`
: `Коротко: нет, денежное операционное нетто не стоит считать чистой прибылью. Отдельно по закрытию счетов 90/91/99 в 1С за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к подтвержденной выручке ${marginPct}` : "; маржа к подтвержденной выручке не рассчитана"}.`
);
lines.push(
"Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность."
directAccountingProfitRequested
? "Это учетный финрезультат по найденным строкам закрытия периода в 1С, не денежный поток, не внешний аудит и не юридически подтвержденная отчетность."
: "Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность."
);
return joinBusinessReplyLines(lines);
}
@ -1110,6 +1178,69 @@ function buildCompactBusinessOverviewReply(
return joinBusinessReplyLines(lines);
}
if (!separateSubject && !crossScopeExecutiveSummary && (actionFamily === "broad_evaluation" || unsupportedFamily === "broad_business_evaluation")) {
const subject = organizationScope ?? "компания";
const periodWithoutPrefix = period.replace(/^за\s+/iu, "");
lines.push(
`Коротко: по доступным данным ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж.`
);
lines.push("Что видно:");
if (incomingAmount) {
lines.push(`- входящие деньги за ${periodWithoutPrefix}: ${incomingAmount};`);
}
if (outgoingAmount) {
lines.push(`- исходящие платежи/списания: ${outgoingAmount};`);
}
if (netAmount) {
lines.push(`- ${netDirection}: ${sentenceAmount(netAmount) ?? netAmount};`);
}
if (customerName && customerAmount) {
lines.push(
topCustomerLooksFinancial
? `- крупнейший входящий источник: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}; это похоже на финансовый контур, не на обычную клиентскую выручку;${nonFinancialCustomer ? ` крупнейший небанковский входящий контрагент: ${nonFinancialCustomer};` : ""}`
: `- крупнейший источник денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}; это сигнал концентрации на крупном заказчике;`
);
}
if (topSupplier) {
lines.push(
topSupplierLooksFinancial
? `- крупнейший получатель исходящих денег: ${topSupplier}; это похоже на финансовый контур, не на обычного поставщика;`
: `- крупнейший получатель исходящих денег: ${topSupplier};`
);
}
const inventoryLine = businessOverviewInventoryLine(overview);
if (inventoryLine) {
lines.push(`- ${localizeLine(inventoryLine)}`);
}
const debtLine = businessOverviewDebtLine(overview);
if (debtLine) {
lines.push(`- ${localizeLine(debtLine)}`);
}
lines.push(
"Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль."
);
const missingOverviewFamilies: string[] = [];
if (!businessOverviewTaxLine(overview)) {
missingOverviewFamilies.push("НДС/налоговая позиция без отдельного точного расчета");
}
if (!debtLine) {
missingOverviewFamilies.push("долги без даты среза");
}
if (!inventoryLine) {
missingOverviewFamilies.push("склад без даты среза");
}
if (missingOverviewFamilies.length > 0) {
lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`);
}
if (limitLine) {
lines.push(limitLine);
}
lines.push(
"Что проверить дальше: чистую прибыль через 90/91/99, маржинальность по проектам, зависимость от топ-3 контрагентов, старые складские остатки и зависшие расчеты."
);
return joinBusinessReplyLines(lines);
}
if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) {
lines.push(
`Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.`
@ -1128,6 +1259,40 @@ function buildCompactBusinessOverviewReply(
}
if (rankingNeed) {
const explicitPeriodRankingOverview =
period &&
!/(?:все\s+доступное|все\s+время|all\s+time)/iu.test(period) &&
(incomingAmount || outgoingAmount || netAmount);
if (explicitPeriodRankingOverview) {
lines.push(
`Коротко: ${organizationPrefix}${period} денежная картина подтверждена по найденным строкам 1С.`
);
lines.push(
`Деньги: входящие ${incomingAmount ?? "0 руб."}, исходящие ${outgoingAmount ?? "0 руб."}, расчетное операционное нетто ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
);
if (customerName && customerAmount) {
lines.push(
topCustomerLooksFinancial
? `Топ входящих: 1. ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}. Это финансовый/банковский контур, не считаю его клиентской выручкой без назначения платежа.${nonFinancialCustomer ? ` 2. Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
: `Крупнейший входящий контрагент: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`
);
}
if (topSupplier) {
lines.push(
topSupplierLooksFinancial
? `Топ исходящих: 1. ${topSupplier}. Это финансовый/банковский контур, не считаю его обычным поставщиком без назначения платежа и договора.${nonFinancialSupplier ? ` 2. Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}`
: `Крупнейший получатель исходящих денег: ${topSupplier}.`
);
}
lines.push(
`Вывод: по движению денег период ${netDirection}; это не чистая прибыль и не бухгалтерский финрезультат.`
);
if (requestedFinancialBoundaryLine) {
lines.push(requestedFinancialBoundaryLine);
}
lines.push("Следующий шаг: могу отдельно посчитать чистую прибыль через закрытие 90/91/99 или разложить этот период по контрагентам.");
return joinBusinessReplyLines(lines);
}
const incomingLeader = strongestIncomingYear(overview);
const canRankYearlyNet = !limitLine;
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
@ -1268,6 +1433,17 @@ function statusFrom(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract |
return "not_applicable";
}
function hasGuardedHotHandoff(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null): boolean {
const bridge = toRecordObject(entryPoint?.bridge);
const handoff = toRecordObject(bridge?.execution_handoff);
return (
entryPoint?.hot_runtime_wired === true &&
bridge?.hot_runtime_wired === true &&
handoff?.can_use_guarded_response === true &&
handoff?.must_keep_internal_mechanics_hidden === true
);
}
function replyTypeFor(
status: AssistantMcpDiscoveryResponseCandidateStatus
): AssistantMcpDiscoveryResponseCandidateContract["reply_type"] {
@ -1318,9 +1494,15 @@ export function buildAssistantMcpDiscoveryResponseCandidate(
): AssistantMcpDiscoveryResponseCandidateContract {
const entry = entryPoint ?? null;
const status = statusFrom(entry);
const guardedHotHandoff = hasGuardedHotHandoff(entry);
const reasonCodes = uniqueStrings(entry?.reason_codes ?? []);
pushReason(reasonCodes, `mcp_discovery_response_candidate_${status}`);
pushReason(reasonCodes, "mcp_discovery_response_candidate_not_hot_wired");
pushReason(
reasonCodes,
guardedHotHandoff
? "mcp_discovery_response_candidate_guarded_hot_wired"
: "mcp_discovery_response_candidate_not_hot_wired"
);
const replyText =
entry && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate")
@ -1331,11 +1513,12 @@ export function buildAssistantMcpDiscoveryResponseCandidate(
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryResponseCandidate",
candidate_status: replyText ? status : status === "clarification_candidate" ? status : status,
hot_runtime_wired: false,
hot_runtime_wired: guardedHotHandoff,
reply_type: replyTypeFor(status),
reply_text: replyText,
eligible_for_future_hot_runtime:
Boolean(replyText) && (status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"),
Boolean(replyText) &&
(status === "ready_for_guarded_use" || status === "checked_sources_only_candidate" || status === "clarification_candidate"),
must_keep_internal_mechanics_hidden: true,
reason_codes: reasonCodes
};

View File

@ -216,6 +216,7 @@ function isDetectedIntentAlignedWithTurnMeaning(
normalizedIntent === "inventory_purchase_provenance_for_item" ||
normalizedIntent === "inventory_purchase_documents_for_item" ||
normalizedIntent === "inventory_sale_trace_for_item" ||
normalizedIntent === "inventory_margin_ranking_for_nomenclature" ||
normalizedIntent === "inventory_profitability_for_item" ||
normalizedIntent === "inventory_purchase_to_sale_chain"
) {
@ -276,7 +277,7 @@ function isExplicitMetadataDiscoveryTurn(
}
function isInventoryExactAddressIntent(intent: string | null): boolean {
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(
return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(
String(intent ?? "")
);
}
@ -305,13 +306,17 @@ function isOpenScopeValueFlowWithoutSubject(
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): boolean {
const graph = readDiscoveryDataNeedGraph(entryPoint);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const businessFactFamily = toNonEmptyString(graph?.business_fact_family);
const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : [];
const reasonCodes = Array.isArray(graph?.reason_codes) ? graph.reason_codes : [];
const reasonCodes = readStringArray(graph?.reason_codes);
const clarificationGaps = readStringArray(graph?.clarification_gaps);
const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
return (
businessFactFamily === "value_flow" &&
subjectCandidates.length === 0 &&
reasonCodes.some((reason) => toNonEmptyString(reason) === "data_need_graph_open_scope_total_without_subject")
(reasonCodes.includes("data_need_graph_open_scope_total_without_subject") ||
(Boolean(explicitOrganizationScope) && clarificationGaps.includes("subject")))
);
}
@ -491,6 +496,31 @@ function hasExactBankOperationsAddressReply(
);
}
function hasInventoryMarginRankingAddressReply(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): boolean {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
if (!toNonEmptyString(input.currentReply)) {
return false;
}
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const capabilityId =
toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id);
return Boolean(
detectedIntent === "inventory_margin_ranking_for_nomenclature" ||
selectedRecipe === "address_inventory_margin_ranking_for_nomenclature_v1" ||
capabilityId === "inventory_inventory_margin_ranking_for_nomenclature"
);
}
function hasValueFlowActionConflictWithDiscoveryTurnMeaning(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
@ -608,6 +638,9 @@ function hasRuntimeAdjustedExactReply(
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
@ -637,6 +670,9 @@ function hasRuntimeMatchedExactReply(
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
@ -663,6 +699,9 @@ function hasAlignedFactualAddressReply(
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
@ -728,6 +767,9 @@ function hasMatchedFactualAddressContinuationTarget(
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const dialogContinuationContract =
toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract) ??
@ -780,6 +822,9 @@ function hasFullConfirmedFactualAddressReply(
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) {
return false;
}
return hasFullConfirmedTruth(input);
}
@ -814,6 +859,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
entryPoint
);
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(
@ -897,6 +943,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
if (exactBankOperationsAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_bank_operations_address_reply");
}
if (inventoryMarginRankingAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply");
}
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
pushReason(
reasonCodes,
@ -909,6 +958,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
if (!candidate.eligible_for_future_hot_runtime) {
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_not_eligible");
}
if (candidate.hot_runtime_wired) {
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_guarded_hot_wired");
}
if (!toNonEmptyString(candidate.reply_text)) {
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_missing_reply_text");
}
@ -929,6 +981,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
!staleMetadataDiscoveryFallbackAgainstExactAddressReply &&
!exactValueFlowReplyForBusinessOverviewDirectMoneyNeed &&
!exactBankOperationsAddressReply &&
!inventoryMarginRankingAddressReply &&
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
candidate.eligible_for_future_hot_runtime &&

View File

@ -7,6 +7,10 @@ import {
type AssistantMcpDiscoveryPilotExecutionContract,
type AssistantMcpDiscoveryPilotExecutorDeps
} from "./assistantMcpDiscoveryPilotExecutor";
import {
buildAssistantMcpDiscoveryExecutionHandoff,
type AssistantMcpDiscoveryExecutionHandoffContract
} from "./assistantMcpDiscoveryExecutionHandoff";
import {
planAssistantMcpDiscovery,
type AssistantMcpDiscoveryChainId,
@ -79,6 +83,9 @@ export interface AssistantMcpRouteCandidateContract {
business_fact_family: string | null;
action_family: string | null;
proof_expectation: string | null;
evidence_plan_status: string | null;
evidence_answer_mode: string | null;
evidence_expected_coverage: string | null;
required_axes: string[];
provided_axes: string[];
missing_axes: string[];
@ -92,12 +99,13 @@ export interface AssistantMcpDiscoveryRuntimeBridgeContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryRuntimeBridge";
bridge_status: AssistantMcpDiscoveryRuntimeBridgeStatus;
hot_runtime_wired: false;
hot_runtime_wired: boolean;
planner: AssistantMcpDiscoveryPlannerContract;
pilot: AssistantMcpDiscoveryPilotExecutionContract;
answer_draft: AssistantMcpDiscoveryAnswerDraftContract;
loop_state: AssistantMcpDiscoveryLoopStateContract;
route_candidate: AssistantMcpRouteCandidateContract;
execution_handoff: AssistantMcpDiscoveryExecutionHandoffContract;
user_facing_response_allowed: boolean;
business_fact_answer_allowed: boolean;
requires_user_clarification: boolean;
@ -326,8 +334,14 @@ function buildRouteCandidate(
bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus
): AssistantMcpRouteCandidateContract {
const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? [];
const providedAxes = flattenAxes(pilot, "provided_axes");
const missingAxes = plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options");
const evidenceAxes = planner.evidence_plan.evidence_axes;
const providedAxes = uniqueStrings([...evidenceAxes.provided_axes, ...flattenAxes(pilot, "provided_axes")]);
const missingAxes =
plannerClarificationGaps.length > 0
? plannerClarificationGaps
: evidenceAxes.user_actionable_missing_axes.length > 0
? evidenceAxes.user_actionable_missing_axes
: flattenAxes(pilot, "missing_axis_options");
const missingProofFamily = routeCandidateMissingProofFamily(planner, pilot);
const candidateStatus = routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily);
return {
@ -338,9 +352,12 @@ function buildRouteCandidate(
selected_chain_summary: planner.selected_chain_summary,
nearest_catalog_chain_template: planner.catalog_chain_template_alignment.top_chain_template_match,
catalog_alignment_status: planner.catalog_chain_template_alignment.alignment_status,
business_fact_family: planner.data_need_graph?.business_fact_family ?? null,
action_family: planner.data_need_graph?.action_family ?? null,
proof_expectation: planner.data_need_graph?.proof_expectation ?? null,
business_fact_family: planner.evidence_plan.data_need.business_fact_family,
action_family: planner.evidence_plan.data_need.action_family,
proof_expectation: planner.evidence_plan.data_need.proof_expectation,
evidence_plan_status: planner.evidence_plan.planner_status,
evidence_answer_mode: planner.evidence_plan.answer_contract.answer_mode,
evidence_expected_coverage: planner.evidence_plan.coverage_gate.expected_coverage,
required_axes: [...planner.required_axes],
provided_axes: providedAxes,
missing_axes: missingAxes,
@ -348,7 +365,7 @@ function buildRouteCandidate(
enablement_reason: routeCandidateEnablementReason(candidateStatus, pilot, missingAxes, missingProofFamily),
recommended_next_action: routeCandidateNextAction(candidateStatus),
forbidden_overclaim_flags: uniqueStrings([
...(planner.data_need_graph?.forbidden_overclaim_flags ?? []),
...planner.evidence_plan.answer_contract.forbidden_overclaim_flags,
...(missingProofFamily ? [missingProofFamily.must_not_claim] : [])
])
};
@ -402,6 +419,16 @@ export async function runAssistantMcpDiscoveryRuntimeBridge(
const bridgeStatus = bridgeStatusFor(pilot, answerDraft);
const loopState = buildLoopState(planner, pilot, bridgeStatus);
const routeCandidate = buildRouteCandidate(planner, pilot, bridgeStatus);
const userFacingResponseAllowed = bridgeStatus !== "blocked";
const businessFactAllowed = businessFactAnswerAllowed(answerDraft);
const executionHandoff = buildAssistantMcpDiscoveryExecutionHandoff({
bridgeStatus,
routeCandidate,
pilot,
answerDraft,
businessFactAnswerAllowed: businessFactAllowed,
userFacingResponseAllowed
});
const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]);
pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`);
@ -409,19 +436,24 @@ export async function runAssistantMcpDiscoveryRuntimeBridge(
pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`);
pushReason(reasonCodes, "runtime_bridge_route_candidate_built");
pushReason(reasonCodes, `runtime_bridge_route_candidate_${routeCandidate.candidate_status}`);
pushReason(reasonCodes, `runtime_bridge_execution_handoff_${executionHandoff.handoff_status}`);
for (const reasonCode of executionHandoff.reason_codes) {
pushReason(reasonCodes, reasonCode);
}
return {
schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
bridge_status: bridgeStatus,
hot_runtime_wired: false,
hot_runtime_wired: executionHandoff.can_use_guarded_response,
planner,
pilot,
answer_draft: answerDraft,
loop_state: loopState,
route_candidate: routeCandidate,
user_facing_response_allowed: bridgeStatus !== "blocked",
business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft),
execution_handoff: executionHandoff,
user_facing_response_allowed: userFacingResponseAllowed,
business_fact_answer_allowed: businessFactAllowed,
requires_user_clarification: bridgeStatus === "needs_clarification",
reason_codes: reasonCodes
};

View File

@ -26,7 +26,7 @@ export interface AssistantMcpDiscoveryRuntimeEntryPointContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint";
entry_status: AssistantMcpDiscoveryRuntimeEntryPointStatus;
hot_runtime_wired: false;
hot_runtime_wired: boolean;
discovery_attempted: boolean;
turn_input: AssistantMcpDiscoveryTurnInputContract;
bridge: AssistantMcpDiscoveryRuntimeBridgeContract | null;
@ -108,13 +108,18 @@ export async function runAssistantMcpDiscoveryRuntimeEntryPoint(
});
const reasonCodes = uniqueStrings([...turnInput.reason_codes, ...bridge.reason_codes]);
pushReason(reasonCodes, "runtime_entry_point_bridge_executed");
pushReason(reasonCodes, "runtime_entry_point_not_wired_to_hot_assistant_answer");
pushReason(
reasonCodes,
bridge.hot_runtime_wired
? "runtime_entry_point_wired_to_guarded_hot_assistant_answer"
: "runtime_entry_point_not_wired_to_hot_assistant_answer"
);
return {
schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_ENTRY_POINT_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
entry_status: "bridge_executed",
hot_runtime_wired: false,
hot_runtime_wired: bridge.hot_runtime_wired,
discovery_attempted: true,
turn_input: turnInput,
bridge,

View File

@ -3,6 +3,7 @@ import {
buildAssistantMcpDiscoveryDataNeedGraph,
type AssistantMcpDiscoveryDataNeedGraphContract
} from "./assistantMcpDiscoveryDataNeedGraph";
import { resolveOrganizationSelectionFromMessage } from "./assistantOrganizationMatcher";
import { normalizeRussianComparableText, repairAddressMojibakeText } from "./addressTextRepair";
import type {
AssistantMcpDiscoveryMetadataRecommendedPrimitive,
@ -27,6 +28,7 @@ export interface BuildAssistantMcpDiscoveryTurnInputAdapterInput {
followupContext?: Record<string, unknown> | null;
userMessage?: string | null;
effectiveMessage?: string | null;
knownOrganizations?: unknown[] | null;
}
export interface AssistantMcpDiscoveryTurnInputContract {
@ -351,6 +353,18 @@ function collectEntityCandidates(value: unknown): string[] {
return result;
}
function collectReasonCodes(value: unknown): string[] {
const result: string[] = [];
if (!Array.isArray(value)) {
const reason = toNonEmptyString(value);
return reason ? [reason] : result;
}
for (const item of value) {
pushUnique(result, item);
}
return result;
}
function collectPredecomposeEntities(predecompose: Record<string, unknown> | null): {
counterparty: string | null;
organization: string | null;
@ -1044,12 +1058,17 @@ function hasBusinessOverviewContinuationSignal(text: string): boolean {
/(?:\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043d\u0435\u0442\u0442\u043e|\u0434\u0435\u043d\p{L}*|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|received|paid|net|cash|customer|supplier)/iu.test(
normalized
);
const hasCashflowPolarityCue =
/(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)[\s\S]{0,80}(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*)|(?:\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u043d\u0435\u0442\u0442\u043e)[\s\S]{0,80}(?:\u043f\u043e\s+\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\p{L}*)/iu.test(
normalized
);
return (
hasEvidenceContinuationCue ||
hasAnalystContinuationCue ||
hasTaxContinuationCue ||
hasFinalSummaryCue ||
hasMoneyBreakdownCue
hasMoneyBreakdownCue ||
hasCashflowPolarityCue
);
}
@ -1114,6 +1133,13 @@ function hasBusinessOverviewFollowupSeed(followupSeed: ReturnType<typeof collect
}
function hasValueFlowSignal(text: string): boolean {
if (
/(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\u043a|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b|\u0441\u043f\u0438\u0441\u0430\u043d|\u0440\u0430\u0441\u0445\u043e\u0434|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u043b\u0443\u0447(?:\u0438\u043b|\u0435\u043d\u043e|\u0435\u043d)|\u043f\u043e\u0441\u0442\u0443\u043f\u0438\u043b|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d|\u0434\u0435\u043d\p{L}*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow|\bearn(?:ed|ing|ings)?\b)/iu.test(
text
)
) {
return true;
}
return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|входящ|получ(?:ил|ено|ен)|поступил|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|(?<!\p{L})заработ(?:ал|али|ало|аем|ает|ать|ано|ок)(?!\p{L})|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow|\bearn(?:ed|ing|ings)?\b)/iu.test(
text
);
@ -1132,6 +1158,13 @@ function hasPayoutSignal(text: string): boolean {
}
function hasBidirectionalValueFlowSignal(text: string): boolean {
if (
/(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0431\u0430\u043b\u0430\u043d\u0441\s+(?:\u043f\u043b\u0430\u0442|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436)|\u0432\u0437\u0430\u0438\u043c\u043e\u0440\u0430\u0441\u0447[\u0435\u0451]\u0442|\u043f\u043e\u043b\u0443\u0447\p{L}*.*(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*|(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*.*\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\u044f\u0449.*\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449.*\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u0440\u0438\u0448\p{L}*.*\u0443\u0448\p{L}*|\u0443\u0448\p{L}*.*\u043f\u0440\u0438\u0448\p{L}*|\u043f\u043b\u044e\u0441.*\u043c\u0438\u043d\u0443\u0441|\u043c\u0438\u043d\u0443\u0441.*\u043f\u043b\u044e\u0441|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(
text
)
) {
return true;
}
return /(?:нетто|сальдо|баланс\s+(?:плат|денег|денеж)|взаиморасч[её]т|получил[иа]?.*(?:за)?платил|(?:за)?платил[иа]?.*получил|входящ.*исходящ|исходящ.*входящ|дебет.*кредит|кредит.*дебет|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(
text
);
@ -1178,6 +1211,46 @@ function extractOrganizationScopeFromRawText(value: unknown): string | null {
);
}
function extractOrganizationScopeFromSemanticText(value: unknown): string | null {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const match = text.match(
/(?:^|[\s,;:])(?:\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u0438|\u0438\u044f)|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446(?:\u0438\u0438|\u0438\u044f))\s+(.+?)(?=$|[\n,.;:!?]|\s+\u0437\u0430\s+(?:\d{4}|\d{2}\s*\u0433|\d{2}\s+\u0433\u043e\u0434)|\s+\u043d\u0430\s+(?:\d{4}|\d{2}\s*\u0433|\d{2}\s+\u0433\u043e\u0434))/iu
);
return toNonEmptyString(match?.[1]);
}
function normalizeLooseOrganizationAlias(value: string | null): string | null {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const normalized = text
.replace(/^[«"']+|[»"']+$/gu, "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return null;
}
const comparable = normalizeRussianComparableText(normalized);
const normalizedTokens = normalized.split(/\s+/u).filter(Boolean);
const hasYearOnlyTimeTail =
/\b(?:19|20)\d{2}\b/u.test(normalized) &&
normalizedTokens.length <= 4 &&
!/(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e|llc|inc|corp)/iu.test(
comparable
);
if (hasYearOnlyTimeTail) {
return null;
}
if (/^(?:\u0438|\u0432|\u0432\u043e|\u0437\u0430|\u043d\u0430|\u043f\u043e|\u043a\u0442\u043e|\u0447\u0442\u043e|\u043a\u0430\u043a(?:\u043e\u0439|\u0430\u044f|\u0438\u0435)?|\u0433\u043b\u0430\u0432\u043d\p{L}*)\b/iu.test(comparable)) {
return null;
}
return normalized;
}
function hasMonthlyAggregationSignal(text: string): boolean {
return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test(
text
@ -1451,7 +1524,10 @@ function metadataScopeHintFromRawText(text: string): string | null {
}
function hasExplicitDateScopeLiteral(text: string): boolean {
return /(?:\b(?:19|20)\d{2}\b|\b\d{4}-\d{2}-\d{2}\b|\b\d{4}-\d{2}\b)/iu.test(text);
if (/\b\d{2}\s*(?:\u0433(?:\u043e\u0434(?:\u0430|\u0443)?)?\.?)\b/iu.test(text)) {
return true;
}
return /(?:\b(?:19|20)\d{2}\b|\b\d{4}-\d{2}-\d{2}\b|\b\d{4}-\d{2}\b|\b\d{2}\s*(?:г(?:од(?:а|у)?)?\.?))\b/iu.test(text);
}
function stripNegatedTaxDateScopeClauses(text: string): string {
@ -1483,6 +1559,20 @@ function collectDateScopeFromRawText(text: string): string | null {
if (year?.[1]) {
return year[1];
}
const utf8ShortYear = text.match(/\b(\d{2})\s*(?:\u0433(?:\u043e\u0434(?:\u0430|\u0443)?)?\.?)\b/iu);
if (utf8ShortYear?.[1]) {
const numericYear = Number.parseInt(utf8ShortYear[1], 10);
if (Number.isFinite(numericYear)) {
return String(numericYear <= 30 ? 2000 + numericYear : 1900 + numericYear);
}
}
const shortYear = text.match(/\b(\d{2})\s*(?:г(?:од(?:а|у)?)?\.?)\b/iu);
if (shortYear?.[1]) {
const numericYear = Number.parseInt(shortYear[1], 10);
if (Number.isFinite(numericYear)) {
return String(numericYear <= 30 ? 2000 + numericYear : 1900 + numericYear);
}
}
return null;
}
@ -1598,17 +1688,51 @@ export function buildAssistantMcpDiscoveryTurnInput(
const reasonCodes: string[] = [];
const rawUserText = toNonEmptyString(input.userMessage);
const rawEffectiveText = toNonEmptyString(input.effectiveMessage);
const knownOrganizations = Array.isArray(input.knownOrganizations) ? input.knownOrganizations : [];
const repairedUserText = rawUserText ? repairAddressMojibakeText(rawUserText) : null;
const repairedEffectiveText = rawEffectiveText ? repairAddressMojibakeText(rawEffectiveText) : null;
const rawUserSignalSourceText = repairedUserText ?? rawUserText ?? "";
const rawSignalSourceText = `${repairedUserText ?? rawUserText ?? ""} ${repairedEffectiveText ?? rawEffectiveText ?? ""}`.trim();
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
const rawUserEntitySourceText = rawUserSignalSourceText || rawEntitySourceText;
const rawUserTextOnly = compactLower(rawUserSignalSourceText);
const rawAssistantEntityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates);
const rawUserPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawUserTextOnly);
const rawUserLifecyclePivotTextSignal =
!rawUserPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawUserTextOnly);
const rawUserBidirectionalValueFlowPivotTextSignal =
!rawUserPrimaryBusinessOverviewSignal &&
!rawUserLifecyclePivotTextSignal &&
hasBidirectionalValueFlowSignal(rawUserTextOnly);
const rawUserScopedEntityCandidate = rawUserSignalSourceText
? rawScopedEntityCandidateFromText(rawUserEntitySourceText)
: null;
const rawUserCounterpartyBidirectionalOverride = Boolean(
rawUserBidirectionalValueFlowPivotTextSignal &&
(rawUserScopedEntityCandidate ||
predecomposeEntities.counterparty ||
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate)))
);
const rawText = compactLower(rawSignalSourceText);
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(
repairedUserText ?? rawUserText ?? ""
);
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
const rawPrimaryBusinessOverviewSignal =
hasBusinessOverviewSignal(rawText) && !rawUserCounterpartyBidirectionalOverride;
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText);
const rawLifecyclePivotTextSignal =
!rawPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowPivotTextSignal =
!rawPrimaryBusinessOverviewSignal &&
!rawLifecyclePivotTextSignal &&
hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowPivotTextSignal =
!rawPrimaryBusinessOverviewSignal &&
!rawLifecyclePivotTextSignal &&
(hasValueFlowSignal(rawText) ||
hasValueRankingSignal(rawText) ||
rawBidirectionalValueFlowPivotTextSignal);
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(
explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal
);
@ -1634,7 +1758,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
!rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
const rawValueFlowAggregateQuestionSignal =
rawValueFlowSignal && hasValueFlowAggregateQuestionSignal(rawText);
(rawValueFlowSignal || rawValueFlowPivotTextSignal) && hasValueFlowAggregateQuestionSignal(rawText);
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText);
const dateScopeSignalText = stripNegatedTaxDateScopeClauses(rawText);
@ -1670,6 +1794,10 @@ export function buildAssistantMcpDiscoveryTurnInput(
const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family);
const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis);
const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family);
const assistantTurnMeaningReasonCodes = collectReasonCodes(assistantTurnMeaning?.reason_codes);
const compactCashflowDisplayFollowupSignal = assistantTurnMeaningReasonCodes.includes(
"compact_cashflow_display_current_turn_signal"
);
const broadBusinessEvaluationUnsupported = unsupported === "broad_business_evaluation";
const seededBusinessOverviewSignal =
broadBusinessEvaluationUnsupported ||
@ -1702,6 +1830,74 @@ export function buildAssistantMcpDiscoveryTurnInput(
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_evaluation";
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(
rawAssistantTurnMeaningOrganizationScope
)
? null
: rawAssistantTurnMeaningOrganizationScope;
const organizationSelectionFromKnown =
resolveOrganizationSelectionFromMessage(rawUserText ?? rawEffectiveText ?? rawSignalSourceText, knownOrganizations) ??
resolveOrganizationSelectionFromMessage(rawSignalSourceText, knownOrganizations) ??
null;
const rawOrganizationScope =
extractOrganizationScopeFromRawText(rawUserText) ??
extractOrganizationScopeFromRawText(rawEffectiveText) ??
extractOrganizationScopeFromRawText(rawSignalSourceText);
const semanticOrganizationScope =
normalizeLooseOrganizationAlias(
extractOrganizationScopeFromSemanticText(rawEffectiveText) ??
extractOrganizationScopeFromSemanticText(rawUserText) ??
extractOrganizationScopeFromSemanticText(rawSignalSourceText)
);
const predecomposeCounterpartyAsOrganizationScope =
(rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
semanticOrganizationScope &&
!rawScopedEntityCandidate &&
!predecomposeEntities.organization
? normalizeLooseOrganizationAlias(predecomposeEntities.counterparty)
: null;
const rawOrganizationMentionSignal =
hasOrganizationScopeSignalUtf8(rawText) ||
Boolean(organizationSelectionFromKnown || rawOrganizationScope || semanticOrganizationScope || predecomposeCounterpartyAsOrganizationScope);
const currentTurnFreshOrganizationScope =
organizationSelectionFromKnown ??
predecomposeEntities.organization ??
rawOrganizationScope ??
semanticOrganizationScope ??
predecomposeCounterpartyAsOrganizationScope;
const currentTurnOrganizationScope =
currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(
predecomposeEntities.counterparty,
predecomposeEntities.organization
);
const organizationMirrorsPredecomposeCounterpartyForPivot = Boolean(
sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
sameScopedName(predecomposeEntities.counterparty, currentTurnOrganizationScope) ||
predecomposeOrganizationMirrorsCounterparty
);
const normalizedPredecomposeCounterpartyForPivot =
organizationMirrorsPredecomposeCounterpartyForPivot
? null
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
const rawExplicitCounterpartyPivotCandidate =
rawScopedEntityCandidate ??
rawAssistantEntityCandidates.find(
(candidate) =>
!isInvalidEntityCandidate(candidate) &&
!sameScopedName(candidate, currentTurnOrganizationScope)
) ??
normalizedPredecomposeCounterpartyForPivot ??
null;
const businessOverviewCounterpartyValueFlowPivot = Boolean(
businessOverviewContinuationSignal &&
!rawPrimaryBusinessOverviewSignal &&
rawValueFlowPivotTextSignal &&
rawExplicitCounterpartyPivotCandidate &&
(rawTopicSwitchSignal || rawValueFlowAggregateQuestionSignal)
);
const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal
? "inventory_reserve_liquidation_boundary"
: debtDueDateBusinessOverviewSignal
@ -1712,8 +1908,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
? "profit_margin_boundary"
: "broad_business_evaluation";
const businessOverviewSignal =
rawBusinessOverviewSignal ||
seededBusinessOverviewSignal;
!businessOverviewCounterpartyValueFlowPivot &&
(rawBusinessOverviewSignal || seededBusinessOverviewSignal);
const businessOverviewSeparateCounterpartySignal = Boolean(
businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText)
);
@ -1735,18 +1931,6 @@ export function buildAssistantMcpDiscoveryTurnInput(
hasSimpleMovementLanePivotSignal(rawText) ||
hasMovementEvidenceFollowupSignal(rawText) ||
hasPronounMovementEvidenceFollowupSignal(rawText);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(
rawAssistantTurnMeaningOrganizationScope
)
? null
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
const currentTurnOrganizationScope =
currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const followupCounterpartyIsMetadataOrganizationScope = Boolean(
followupSeed.subjectResolutionOptional &&
followupSeed.counterparty &&
@ -1792,10 +1976,6 @@ export function buildAssistantMcpDiscoveryTurnInput(
!rawBidirectionalValueFlowSignal &&
explicitOrganizationScopeSignal
);
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(
predecomposeEntities.counterparty,
predecomposeEntities.organization
);
const organizationMirrorsPredecomposeCounterparty = Boolean(
(rawBidirectionalValueFlowSignal ||
hasValueRankingSignal(rawText) ||
@ -2130,16 +2310,27 @@ export function buildAssistantMcpDiscoveryTurnInput(
const bidirectionalValueFlowSignal =
!businessOverviewSignal &&
!lifecycleSignal &&
(rawBidirectionalValueFlowSignal || seededAction === "net_value_flow");
((businessOverviewCounterpartyValueFlowPivot
? rawBidirectionalValueFlowPivotTextSignal
: rawBidirectionalValueFlowSignal) ||
seededAction === "net_value_flow");
const valueFlowSignal =
!businessOverviewSignal &&
!lifecycleSignal &&
!metadataGroundedMovementLaneApplicable &&
(rawValueFlowSignal || seededDomain === "counterparty_value");
((businessOverviewCounterpartyValueFlowPivot
? rawValueFlowPivotTextSignal
: rawValueFlowSignal) ||
seededDomain === "counterparty_value");
const payoutSignal =
valueFlowSignal &&
!bidirectionalValueFlowSignal &&
(rawPayoutSignal || seededAction === "payout");
((businessOverviewCounterpartyValueFlowPivot
? rawValueFlowPivotTextSignal &&
!rawBidirectionalValueFlowPivotTextSignal &&
hasPayoutSignal(rawText)
: rawPayoutSignal) ||
seededAction === "payout");
const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable
? "metadata lane clarification"
: semanticNeedFor({
@ -2147,17 +2338,37 @@ export function buildAssistantMcpDiscoveryTurnInput(
? "movements"
: businessOverviewSignal
? "business_overview"
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? "counterparty_value"
: rawDomain ?? seededDomain,
action: explicitVatMovementEvidenceSignal
? "list_movements"
: businessOverviewSignal
? businessOverviewActionFamily
: lifecycleSignal
? "activity_duration"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "net_value_flow"
: payoutSignal
? "payout"
: rawAction ?? seededAction ?? "turnover"
: rawAction ?? seededAction,
unsupported: explicitVatMovementEvidenceSignal
? "movement_evidence"
: businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ?? seededUnsupported,
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: unsupported ?? seededUnsupported,
lifecycleSignal,
valueFlowSignal,
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
@ -2322,11 +2533,17 @@ export function buildAssistantMcpDiscoveryTurnInput(
const clarificationLoopStillNeedsPeriod = Boolean(
followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period")
);
const rawTwoDigitPredecomposeYearHint = Boolean(
predecomposeDateScope &&
/^\d{4}$/.test(predecomposeDateScope) &&
/\b\d{2}\b/u.test(rawText)
);
const businessOverviewRawWithoutDateScope = Boolean(
businessOverviewSignal &&
!rawAllTimeScopeSignal &&
!explicitDateScopeLiteralDetected &&
!rawDateScope &&
!rawTwoDigitPredecomposeYearHint &&
!relativeCurrentDateHintDetected
);
const predecomposeDateScopeCountsAsCurrentTurnPeriod = Boolean(
@ -2352,6 +2569,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal);
const topicSwitchSuppressesFollowupScope = Boolean(
rawTopicSwitchSignal &&
!compactCashflowDisplayFollowupSignal &&
(rawMetadataSignal ||
businessOverviewSignal ||
rawEntitySearchOverridesStaleScope ||
@ -2469,30 +2687,30 @@ export function buildAssistantMcpDiscoveryTurnInput(
unsupported_but_understood_family:
businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ??
(lifecycleSignal
? "counterparty_lifecycle"
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: metadataGroundedMovementLaneApplicable
? "movement_evidence"
: metadataGroundedDocumentLaneApplicable
? "document_evidence"
: explicitVatMovementEvidenceSignal
? "movement_evidence"
: metadataAmbiguityLaneClarificationApplicable
? "metadata_lane_choice_clarification"
: entityResolutionSignal
? "entity_resolution"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "1c_metadata_surface"
: followupDiscoverySeedApplicable
? seededUnsupported
: null),
: unsupported ??
(metadataGroundedMovementLaneApplicable
? "movement_evidence"
: metadataGroundedDocumentLaneApplicable
? "document_evidence"
: explicitVatMovementEvidenceSignal
? "movement_evidence"
: metadataAmbiguityLaneClarificationApplicable
? "metadata_lane_choice_clarification"
: entityResolutionSignal
? "entity_resolution"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "1c_metadata_surface"
: followupDiscoverySeedApplicable
? seededUnsupported
: null),
stale_replay_forbidden: Boolean(
assistantTurnMeaning?.stale_replay_forbidden ||
businessOverviewSignal ||
@ -2763,6 +2981,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (businessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_route_candidate");
}
if (businessOverviewCounterpartyValueFlowPivot) {
pushReason(reasonCodes, "mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow");
}
if (businessOverviewContinuationSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
}
@ -2787,6 +3008,15 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (businessOverviewRawYearOverridesPredecomposeAsOf) {
pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
}
if (organizationSelectionFromKnown) {
pushReason(reasonCodes, "mcp_discovery_organization_scope_from_known_organizations");
}
if (semanticOrganizationScope) {
pushReason(reasonCodes, "mcp_discovery_organization_scope_from_semantic_text");
}
if (predecomposeCounterpartyAsOrganizationScope) {
pushReason(reasonCodes, "mcp_discovery_counterparty_reinterpreted_as_organization_scope");
}
if (
!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
normalizedPredecomposeCounterparty

View File

@ -28,6 +28,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@ -41,6 +42,7 @@ const ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS = new Set([
"inventory_purchase_provenance_for_item",
"inventory_purchase_documents_for_item",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain"
]);
@ -286,7 +288,7 @@ export function createAssistantRoutePolicy(deps) {
: null;
const semanticCanonicalRecommended = semanticExtractionContract?.apply_canonical_recommended !== false;
const llmSupportedDeepAddressIntentSignal = llmContractMode === "deep_analysis" &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
/^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_margin_ranking_for_nomenclature|inventory_profitability_for_item|inventory_purchase_to_sale_chain)$/u.test(llmContractIntent ?? "") &&
semanticCanonicalRecommended;
const llmCanonicalEntitySignal = /(?:заказчик|поставщик|контрагент|компан|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase()));
const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis";
@ -395,6 +397,24 @@ export function createAssistantRoutePolicy(deps) {
const hasTemporalCue = /(?:на\s+эту\s+же\s+дат[ауеы]|на\s+тот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return hasRequestCue && hasTemporalCue;
}
function hasInventoryMarginRankingContinuationSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()).replace(/ё/g, "е");
if (!normalized) {
return false;
}
const wantsFoundRows =
/(?:покажи|показать|выведи|дай|раскрой|show|list)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур)/iu.test(normalized);
const account41Not01 =
/\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead)/iu.test(normalized);
const periodExpansion =
/(?:расширь|расширить|возьми|давай|покажи|за|на|до|весь|год|квартал|месяц|expand|period)/iu.test(normalized) &&
/(?:январ|феврал|март|апрел|ма[йяе]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return wantsFoundRows || account41Not01 || periodExpansion;
}
function hasOrganizationClarificationTextCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
@ -563,6 +583,7 @@ export function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const followupPreviousIntent = toNonEmptyString(followupContext?.previous_intent);
const followupRootIntent = toNonEmptyString(followupContext?.root_intent);
const followupPreviousFilters = followupContext?.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: null;
@ -597,6 +618,16 @@ export function createAssistantRoutePolicy(deps) {
hasShortInventoryObjectFollowupSignal(repairedRawUserMessage) ||
hasShortInventoryObjectFollowupSignal(effectiveAddressUserMessage) ||
hasShortInventoryObjectFollowupSignal(repairedEffectiveAddressUserMessage)));
const protectedInventoryMarginRankingFollowup = Boolean(followupContext &&
(followupPreviousIntent === "inventory_margin_ranking_for_nomenclature" ||
followupRootIntent === "inventory_margin_ranking_for_nomenclature") &&
!dataScopeMetaQuery &&
!capabilityMetaQuery &&
!dangerOrCoercionSignal &&
(hasInventoryMarginRankingContinuationSignal(rawUserMessage) ||
hasInventoryMarginRankingContinuationSignal(repairedRawUserMessage) ||
hasInventoryMarginRankingContinuationSignal(effectiveAddressUserMessage) ||
hasInventoryMarginRankingContinuationSignal(repairedEffectiveAddressUserMessage)));
const organizationClarificationContinuationDetected = Boolean((followupContext || continuitySnapshot.hasGroundedAddressContext) &&
lastOrganizationClarificationDebug &&
explicitOrganizationClarificationSelection &&
@ -654,6 +685,7 @@ export function createAssistantRoutePolicy(deps) {
!baseToolGatePreservesAddressLane &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!protectedInventoryShortFollowup &&
!protectedInventoryMarginRankingFollowup &&
!organizationClarificationContinuationDetected &&
!routeCandidateOrganizationClarificationDetected);
const lastAddressAssistantDebug = sessionItems
@ -1165,6 +1197,7 @@ export function createAssistantRoutePolicy(deps) {
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage) ||
protectedInventoryMarginRankingFollowup ||
inventoryRootRestatementFollowupDetected);
const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) ||
hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) ||
@ -1202,7 +1235,9 @@ export function createAssistantRoutePolicy(deps) {
(!llmContractIntent || llmContractIntent === "unknown"));
const exactAddressIntentProtectedFromSemanticDeepHint = laneProtectionArbitration.exactAddressIntentProtectedFromSemanticDeepHint;
const protectAddressLaneFromFallback = Boolean(
laneProtectionArbitration.protectAddressLaneFromFallback || customerValueRankingAddressSignal
laneProtectionArbitration.protectAddressLaneFromFallback ||
customerValueRankingAddressSignal ||
protectedInventoryMarginRankingFollowup
);
const vatExplainFollowupSignal = Boolean(followupContext &&
toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" &&
@ -1283,7 +1318,7 @@ export function createAssistantRoutePolicy(deps) {
let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane");
let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0");
const semanticAddressLaneRecovery = Boolean(!runAddressLane &&
supportedAddressRouteCandidateDetected &&
(supportedAddressRouteCandidateDetected || protectedInventoryMarginRankingFollowup) &&
!deepAnalysisPreferenceDetected &&
!unsupportedAddressIntentFallbackToDeep &&
!deepAnalysisSignalFallbackToDeep &&
@ -1292,7 +1327,9 @@ export function createAssistantRoutePolicy(deps) {
if (semanticAddressLaneRecovery) {
runAddressLane = true;
toolGateDecision = "run_address_lane";
toolGateReason = resolvedIntentResolution.intent !== "unknown" || llmContractIntent
toolGateReason = protectedInventoryMarginRankingFollowup
? "followup_context_detected"
: resolvedIntentResolution.intent !== "unknown" || llmContractIntent
? "address_intent_resolver_detected"
: "address_signal_detected";
}

View File

@ -337,6 +337,18 @@ export const INVENTORY_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContrac
answerObjectShape: "inventory_profitability_bundle",
bundleReusePolicy: "sale_trace_bundle_preferred"
}),
inventoryExactCapability({
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
intent_ids: ["inventory_margin_ranking_for_nomenclature"],
entry_modes: ["root_entry", "root_followup", "clarification_resume"],
transitions: ["T1", "T2", "T7"],
requiresFocusObject: false,
requiredAnchors: ["period"],
resultShape: "nomenclature_margin_ranking",
answerObjectShape: "inventory_margin_ranking",
bundleReusePolicy: "none",
scenarioFamilies: ["canonical", "colloquial", "account_41_correction"]
}),
inventoryExactCapability({
capability_id: "inventory_inventory_purchase_to_sale_chain",
intent_ids: ["inventory_purchase_to_sale_chain"],

View File

@ -47,6 +47,31 @@ export function createAssistantTransitionPolicy(deps) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasInventoryMarginRankingFollowupSignal(userMessage, alternateMessage = null, sourceIntentHint = null) {
if (sourceIntentHint !== "inventory_margin_ranking_for_nomenclature") {
return false;
}
return [userMessage, alternateMessage]
.filter((value) => deps.toNonEmptyString(value))
.map((value) =>
deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е")
)
.some((normalized) => {
if (!normalized) {
return false;
}
const wantsFoundRows =
/(?:покажи|показать|выведи|дай|раскрой|show|list|РїРѕРєР°РРё|показаССЊ|РІСведи|РґР°Р|раскроР)/iu.test(normalized) &&
/(?:найденн|строк|реализац|себестоимостн|баз|РЅР°Рденн|СЃССЂРѕРє|реализаС|себесСРѕРёРјРѕСЃСРЅ|баз)/iu.test(normalized) &&
/(?:себестоимостн|реализац|марж|прибыл|номенклатур|себесСРѕРёРјРѕСЃСРЅ|реализаС|марР|РїСЂРёР±СР»|номенклаС)/iu.test(normalized);
const account41Not01 =
/\b41(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/\b01(?:[.,]\d{1,2})?\b/iu.test(normalized) &&
/(?:\bне\b|вместо|а\s+не|not|instead|РЅРµ|вмесСРѕ|Р°\s+РЅРµ)/iu.test(normalized);
return wantsFoundRows || account41Not01;
});
}
function hasSamePeriodReferenceCue(...values) {
return values
.map((value) => normalizeFollowupText(value))
@ -640,9 +665,26 @@ export function createAssistantTransitionPolicy(deps) {
llmPreDecomposeMeta
})
: null;
const hasCompactCashflowFollowupCue = (value) => {
const normalized = normalizeFollowupText(value);
if (!normalized) {
return false;
}
const hasCompactCue = /(?:\u043a\u043e\u0440\u043e\u0442\p{L}*|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u0431\u0435\u0437\s+\u0442\u043e\u043f\p{L}*)/iu.test(normalized);
const hasCashflowCue = /(?:\u0434\u0435\u043d\p{L}*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u043e\u043b\u0443\u0447|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043d\u0435\u0442\u0442\u043e)/iu.test(normalized);
return hasCompactCue && hasCashflowCue;
};
const compactCashflowFollowupSignal =
hasShortValueFlowRetargetCue(userMessage) ||
hasCompactCashflowFollowupCue(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) ||
hasCompactCashflowFollowupCue(String(alternateMessage ?? ""))
: false);
if (
assistantTurnMeaning?.stale_replay_forbidden === true &&
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage)
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage) &&
!compactCashflowFollowupSignal
) {
return null;
}
@ -738,7 +780,8 @@ export function createAssistantTransitionPolicy(deps) {
sourceIntentHint === "customer_revenue_and_payments" ||
sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1";
sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const hasBusinessOverviewCarryoverSourceHint =
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = resolveNavigationSessionContextState(
@ -760,6 +803,11 @@ export function createAssistantTransitionPolicy(deps) {
sourceIntentHint,
hasNavigationInventoryItemFocusHint
);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
userMessage,
alternateMessage,
sourceIntentHint
);
let inventoryShortFollowupPrimary =
(deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage);
@ -777,10 +825,12 @@ export function createAssistantTransitionPolicy(deps) {
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const shortValueFlowRetargetPrimary =
hasValueFlowCarryoverSourceHint && hasShortValueFlowRetargetCue(userMessage);
hasValueFlowCarryoverSourceHint &&
(hasShortValueFlowRetargetCue(userMessage) || hasCompactCashflowFollowupCue(userMessage));
const shortValueFlowRetargetAlternate =
hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) ||
hasCompactCashflowFollowupCue(String(alternateMessage ?? ""))
: false;
const businessOverviewBoundaryFollowupPrimary =
hasBusinessOverviewCarryoverSourceHint && hasBusinessOverviewBoundaryFollowupCue(userMessage);
@ -796,6 +846,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
@ -805,6 +856,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
@ -862,6 +914,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -886,6 +939,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -1054,8 +1108,9 @@ export function createAssistantTransitionPolicy(deps) {
!inventoryShortFollowupPrimary &&
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!foreignAccountingPivotOverInventory &&
!businessOverviewBoundaryFollowupAlternate &&
!inventoryMarginRankingFollowup &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
(!deps.toNonEmptyString(alternateMessage)
@ -1118,6 +1173,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
@ -1129,6 +1185,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
@ -1151,6 +1208,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)

View File

@ -22,6 +22,7 @@ const SUPPORTED_ADDRESS_INTENTS = new Set([
"inventory_purchase_documents_for_item",
"inventory_supplier_stock_overlap_as_of_date",
"inventory_sale_trace_for_item",
"inventory_margin_ranking_for_nomenclature",
"inventory_profitability_for_item",
"inventory_purchase_to_sale_chain",
"inventory_aging_by_purchase_date",
@ -138,6 +139,90 @@ function detectCounterpartyTurnoverFamily(text) {
};
}
function hasCompactOrganizationCashflowDisplaySignal(text) {
const normalized = String(text ?? "");
if (!normalized) {
return false;
}
const hasCompactCue =
/(?:\u043a\u043e\u0440\u043e\u0442\u043a\w*|\u043e\u0434\u043d\u043e\u0439\s+\u0441\u0442\u0440\u043e\u043a\p{L}*|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0438\u0442\u043e\u0433\p{L}*|\u043d\u0435\s+\u043e\u0431\u0437\u043e\u0440|\u043f\u0440\u043e\u0441\u0442\u043e\s+\u0434\u0435\u043d\p{L}*|\u0431\u0435\u0437\s+\u0442\u043e\u043f(?:\u043e\u0432|\u0430)?\b|\u0431\u0435\u0437\s+\u0441\u043f\u0438\u0441(?:\u043a\p{L}*)?|\u0431\u0435\u0437\s+\u0434\u0435\u0442\u0430\u043b\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0431\u0438\u0432\p{L}*|\u0431\u0435\u0437\s+\u0440\u0430\u0437\u0440\u0435\u0437\p{L}*|\u0431\u0435\u0437\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\p{L}*)/iu.test(
normalized
);
if (!hasCompactCue) {
return false;
}
const hasMoneyCue =
/(?:\u0434\u0435\u043d\p{L}*|\u0434\u0435\u043d\u0435\u0436\p{L}*|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0441\u043f\u0438\u0441\u0430\p{L}*|\u043d\u0435\u0442\u0442\u043e|cash|money|incoming|outgoing|net)/iu.test(
normalized
);
const hasOrganizationEarningsCue =
/(?:\u0434\u0430\u0439|\u043f\u043e\u043a\u0430\u0436\p{L}*|\u0438\u0442\u043e\u0433\p{L}*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u0437\u0430\u0440\u0430\u0431\u043e\u0442\p{L}*|\u043f\u0440\u0438\u0448\p{L}*|\u0443\u0448\p{L}*|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0434\u0435\u043d\u0435\u0436\p{L}*\s+\u043d\u0435\u0442\u0442\u043e|how\s+much|show|give|earned|received|paid)/iu.test(
normalized
);
const hasExplicitExclusionCue =
/(?:\u0438\u0441\u043a\u043b\u044e\u0447\p{L}*|\u0443\u0431\u0435\u0440\p{L}*|\u043a\u0440\u043e\u043c\u0435|exclude|excluding|without)[\s\S]{0,80}(?:\u043a\u0440\u0443\u043f\u043d\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\p{L}*|\u043a\u043b\u0438\u0435\u043d\u0442\p{L}*|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\p{L}*|\u043e\u043f\u0435\u0440\u0430\u0446\p{L}*|counterpart|customer|supplier|operation)/iu.test(
normalized
);
const hasSpecificCounterpartyScope = detectScopedCounterpartyEntity(normalized) !== null;
return hasMoneyCue && hasOrganizationEarningsCue && !hasExplicitExclusionCue && !hasSpecificCounterpartyScope;
}
function detectScopedCounterpartyEntity(text) {
const patterns = [
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+(.+?)(?=$|[,.;:!?]|\s+(?:\u0437\u0430|\u043d\u0430|\u0432|\u0432\u043e|\u043a|\u043f\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a|\u043a\u0430\u043a|\u043a\u0430\u043a\u043e\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436\p{L}*|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0432\u0445\u043e\u0434\p{L}*)(?=$|[\s,.;:!?]))/iu,
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+([\p{L}\d._-]{2,})(?=$|[\s,.;:!?])/iu
];
const ignored = new Set([
"\u0433\u043e\u0434",
"\u0433\u043e\u0434\u0430",
"\u043f\u0435\u0440\u0438\u043e\u0434",
"\u043f\u0435\u0440\u0438\u043e\u0434\u0430",
"\u043c\u0435\u0441\u044f\u0446",
"\u043c\u0435\u0441\u044f\u0446\u0430",
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b",
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430",
"\u0434\u0435\u043d\u044c\u0433\u0438",
"\u043d\u0435\u0442\u0442\u043e",
"\u0441\u0430\u043b\u044c\u0434\u043e",
"year",
"period",
"month",
"quarter",
"net"
]);
for (const pattern of patterns) {
const rawEntity = text.match(pattern)?.[1]?.trim() ?? "";
if (!rawEntity) {
continue;
}
const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim();
if (entity.length >= 2 && !ignored.has(entity)) {
return entity;
}
}
return null;
}
function detectCounterpartyBidirectionalValueFlowFamily(text) {
const hasNetCue =
/(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|net\s+(?:flow|cash|payment)|cash\s+net)/iu.test(text);
const hasIncomingCue =
/(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text);
const hasOutgoingCue =
/(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text);
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) {
return null;
}
const entity = detectScopedCounterpartyEntity(text);
if (!entity) {
return null;
}
return {
family: "counterparty_bidirectional_value_flow_or_netting",
entity
};
}
function hasExplicitCounterpartyValueObject(text) {
return /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u0441\u0434\u0435\u043b\u043a|customer|client|counterparty|supplier|vendor|contract|item|product|deal)/iu.test(
text
@ -379,14 +464,14 @@ function detectBroadBusinessEvaluation(text) {
return null;
}
function buildEntityCandidates(counterpartyTurnover) {
if (!counterpartyTurnover?.entity) {
function buildEntityCandidates(entityFamily) {
if (!entityFamily?.entity) {
return [];
}
return [
{
type: "counterparty",
value: counterpartyTurnover.entity,
value: entityFamily.entity,
source: "current_turn_loose_entity_tail"
}
];
@ -399,10 +484,17 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
const rawText = normalizeTurnText(rawMessage, deps);
const effectiveText = normalizeTurnText(effectiveMessage, deps);
const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`);
const supportedIntent = detectSupportedIntent(joinedText, deps);
const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText);
const compactOrganizationCashflowDisplay = hasCompactOrganizationCashflowDisplaySignal(rawText);
const supportedIntent = compactOrganizationCashflowDisplay ? null : detectSupportedIntent(joinedText, deps);
const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText);
const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText);
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
const broadBusinessEvaluation = selectedObjectInventoryExact ? null : detectBroadBusinessEvaluation(joinedText);
const broadBusinessEvaluation =
compactOrganizationCashflowDisplay
? { family: "broad_business_evaluation" }
: selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family
? null
: detectBroadBusinessEvaluation(joinedText);
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
const explicitIntentCandidate =
broadBusinessEvaluation?.family
@ -410,6 +502,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
const unsupportedFamily = broadBusinessEvaluation?.family
? broadBusinessEvaluation.family
: !explicitIntentCandidate && counterpartyBidirectionalValueFlow?.family
? counterpartyBidirectionalValueFlow.family
: !explicitIntentCandidate && counterpartyTurnover?.family
? counterpartyTurnover.family
: null;
@ -417,12 +511,18 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
if (supportedIntent?.reason) {
reasonCodes.push(supportedIntent.reason);
}
if (counterpartyBidirectionalValueFlow?.family) {
reasonCodes.push("counterparty_bidirectional_value_flow_current_turn_signal");
}
if (counterpartyTurnover?.family) {
reasonCodes.push("counterparty_turnover_current_turn_signal");
}
if (broadBusinessEvaluation?.family) {
reasonCodes.push("broad_business_evaluation_current_turn_signal");
}
if (compactOrganizationCashflowDisplay) {
reasonCodes.push("compact_cashflow_display_current_turn_signal");
}
if (rawText !== normalizeTurnText(rawMessage, { ...deps, repairAddressMojibake: (value) => String(value ?? "") })) {
reasonCodes.push("mojibake_repair_applied");
}
@ -443,6 +543,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
? "inventory"
: broadBusinessEvaluation?.family
? "business_summary"
: counterpartyBidirectionalValueFlow?.family
? "counterparty_value"
: explicitIntentCandidate?.includes("counterparty")
? "counterparty"
: counterpartyTurnover?.family
@ -455,6 +557,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
? "confirmed_snapshot"
: broadBusinessEvaluation?.family
? "broad_evaluation"
: counterpartyBidirectionalValueFlow?.family
? "net_value_flow"
: explicitIntentCandidate === "customer_revenue_and_payments" ||
explicitIntentCandidate === "supplier_payouts_profile"
? "counterparty_value_or_turnover"
@ -470,7 +574,10 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
? "counterparty_value_or_turnover"
: null;
const staleReplayForbidden = Boolean(
unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate)
unsupportedFamily ||
broadBusinessEvaluation?.family ||
(counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) ||
(counterpartyTurnover?.entity && !explicitIntentCandidate)
);
return {
schema_version: "assistant_turn_meaning_v1",
@ -481,10 +588,13 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
asked_domain_family: askedDomainFamily,
asked_action_family: askedActionFamily,
explicit_intent_candidate: explicitIntentCandidate,
explicit_entity_candidates: broadBusinessEvaluation?.family ? [] : buildEntityCandidates(counterpartyTurnover),
explicit_entity_candidates: broadBusinessEvaluation?.family
? []
: buildEntityCandidates(counterpartyBidirectionalValueFlow ?? counterpartyTurnover),
meaning_confidence: broadBusinessEvaluation?.family
? "medium"
: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"),
: supportedIntent?.confidence ??
(counterpartyBidirectionalValueFlow?.family || counterpartyTurnover?.family ? "medium" : "low"),
intent_override_strength: explicitIntentCandidate
? "explicit_current_turn_intent"
: staleReplayForbidden

View File

@ -219,22 +219,23 @@ export function loadCapabilitiesRegistry(): CapabilityRegistry {
}
export function buildCapabilityContractReplyFromRegistry(): string {
const registry = loadCapabilitiesRegistry();
const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6);
const groupLines = topGroups.map((group, index) => {
const examples = group.typical_queries
.slice(0, 2)
.map((query) => query.trim())
.filter((query) => query.length > 0)
.join("; ");
return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`;
});
return [
"Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.",
"По основным группам:",
...groupLines,
"Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.",
"Могу быстро смотреть управленческие вещи по данным 1С в режиме чтения.",
"",
"- кто должен денег и кому должны;",
"- какой год или месяц был самым денежным;",
"- какие контрагенты дают основной поток;",
"- что лежит на складе и какие остатки стареют;",
"- сколько НДС к уплате за период;",
"- какие документы, оплаты и договоры есть по контрагенту.",
"",
"Примеры запросов:",
"- кто самый доходный клиент за все время",
"- что зависло на складе",
"- кому мы должны на сегодня",
"- какое нетто по СВК за 2020",
"- сколько НДС к уплате за 4 квартал 2019",
"",
"Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере."
].join("\n");
}

View File

@ -46,6 +46,7 @@ type V2FamilyFragment = V2Family["fragments"][number];
type RouteQueryClass =
| "exact_object_trace"
| "ranking_or_period_summary"
| "bidirectional_value_flow"
| "symptom_first"
| "lifecycle_first"
| "chain_break"
@ -66,6 +67,10 @@ interface RouteDisciplineRule {
const ACCOUNT_HINT_PATTERN =
/(?:\b(?:account|acct|schet|счет|сч)\s*[:#]?\s*(?:[1-9][0-9](?:[./-][0-9]{1,2})?)|\b(?:19|20|21|23|25|26|28|29|44|51|60|62|68)\b)/i;
const PERIOD_PATTERN = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/i;
const BIDIRECTIONAL_VALUE_FLOW_PATTERN =
/(?:\b(?:receive(?:d)?|received|get|got|incoming|inflow|paid|payment|payments|outgoing|outflow|net|netto|cash\s*flow)\b|получ(?:ить|ил[аи]?|ено|аем|или)|поступ(?:ил[аи]?|ление|ления)|заплат(?:ить|ил[аи]?|или)|оплат(?:ить|ил[аи]?|ы|или)|входящ(?:ий|ие|их)|исходящ(?:ий|ие|их)|нетто|сальдо)/iu;
const COUNTERPARTY_SCOPE_PATTERN =
/(?:\b(?:counterparty|supplier|customer|vendor|client|bank)\b|контрагент|поставщик|покупател|клиент|заказчик|банк|сбербанк|по\s+(?:ип|ооо|пао|зао|оао|группа)\b)/iu;
const SYMPTOM_MARKER_PATTERN =
/(?:\bsymptom\b|\banomaly\b|\bproblem\b|\bissue\b|\btail\b|\bhanging\b|\bblocked\b|\bincomplete\b|remains?\s+open|not\s+(?:confirmed|observed|resolved|closed)|не\s+(?:подтвержден|закрыт|наблюдается)|хвост|сбой|проблем)/i;
const LIFECYCLE_MARKER_PATTERN =
@ -96,6 +101,13 @@ export const ROUTE_DISCIPLINE_RULE_TABLE: RouteDisciplineRule[] = [
forbidden_fallback: ["store_canonical", "hybrid_store_plus_live"],
description: "Ranking and period summary queries require analytical batch path."
},
{
query_class: "bidirectional_value_flow",
required_route: "hybrid_store_plus_live",
allowed_fallback: ["no_route"],
forbidden_fallback: ["store_canonical"],
description: "Scoped bidirectional value-flow questions require hybrid evidence path."
},
{
query_class: "symptom_first",
required_route: "hybrid_store_plus_live",
@ -218,6 +230,21 @@ function hasAccountOrPeriodAnchor(fragment: V2FamilyFragment, lowerText: string)
return fragment.account_hints.length > 0 || ACCOUNT_HINT_PATTERN.test(lowerText) || PERIOD_PATTERN.test(lowerText);
}
function hasBidirectionalValueFlowSignal(fragment: V2FamilyFragment, lowerText: string): boolean {
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
return false;
}
return BIDIRECTIONAL_VALUE_FLOW_PATTERN.test(lowerText);
}
function hasCounterpartyScopeSignal(fragment: V2FamilyFragment, lowerText: string): boolean {
return (
COUNTERPARTY_SCOPE_PATTERN.test(lowerText) ||
fragment.entity_hints.some((hint) => hint.trim().length > 0) ||
fragment.candidate_labels.includes("cross_entity")
);
}
function resolveRouteClass(fragment: V2FamilyFragment): RouteDisciplineRule {
const lowerText = mergedFragmentText(fragment);
const symptomSignal = hasSymptomSignal(fragment, lowerText);
@ -227,6 +254,8 @@ function resolveRouteClass(fragment: V2FamilyFragment): RouteDisciplineRule {
const causalSignal = hasCausalSignal(lowerText);
const ambiguitySignal = hasAmbiguitySignal(fragment, lowerText);
const accountOrPeriodAnchor = hasAccountOrPeriodAnchor(fragment, lowerText);
const bidirectionalValueFlowSignal = hasBidirectionalValueFlowSignal(fragment, lowerText);
const counterpartyScopeSignal = hasCounterpartyScopeSignal(fragment, lowerText);
if (fragment.flags.asks_for_exact_object_trace) {
return ROUTE_DISCIPLINE_RULE_MAP.get("exact_object_trace")!;
@ -234,6 +263,9 @@ function resolveRouteClass(fragment: V2FamilyFragment): RouteDisciplineRule {
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
return ROUTE_DISCIPLINE_RULE_MAP.get("ranking_or_period_summary")!;
}
if (bidirectionalValueFlowSignal && counterpartyScopeSignal) {
return ROUTE_DISCIPLINE_RULE_MAP.get("bidirectional_value_flow")!;
}
if (ambiguitySignal && (symptomSignal || lifecycleSignal || chainBreakSignal || periodImpactSignal || causalSignal)) {
return ROUTE_DISCIPLINE_RULE_MAP.get("mixed_ambiguity")!;
}
@ -272,7 +304,8 @@ function shouldPromoteFromNoRoute(fragment: V2FamilyFragment, rule: RouteDiscipl
hasLifecycleSignal(fragment, lowerText) ||
hasChainBreakSignal(lowerText) ||
hasPeriodImpactSignal(lowerText) ||
hasCausalSignal(lowerText);
hasCausalSignal(lowerText) ||
(hasBidirectionalValueFlowSignal(fragment, lowerText) && hasCounterpartyScopeSignal(fragment, lowerText));
const hasAnchor =
hasAccountOrPeriodAnchor(fragment, lowerText) ||

View File

@ -33,6 +33,7 @@ export type AddressIntent =
| "inventory_supplier_stock_overlap_as_of_date"
| "inventory_sale_trace_for_item"
| "inventory_trading_margin_proxy_for_organization"
| "inventory_margin_ranking_for_nomenclature"
| "inventory_profitability_for_item"
| "inventory_purchase_to_sale_chain"
| "inventory_aging_by_purchase_date"
@ -203,6 +204,7 @@ export interface AddressRecipeDefinition {
| "inventory_supplier_stock_overlap_profile"
| "inventory_sale_trace_profile"
| "inventory_trading_margin_proxy_profile"
| "inventory_margin_ranking_profile"
| "inventory_profitability_profile"
| "inventory_purchase_to_sale_chain_profile"
| "inventory_aging_by_purchase_date_profile"

View File

@ -104,6 +104,15 @@ describe("addressIntentResolver regression bridges", () => {
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("routes nomenclature margin ranking away from OS, bank, and settlement fallbacks", () => {
const result = resolveAddressIntent(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439"
);
expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result.reasons).toContain("unicode_nomenclature_margin_ranking_bridge_signal_detected");
});
it("detects bare historical inventory root with explicit month-year", () => {
const result = resolveAddressIntent("остатки на март 2016");

View File

@ -104,4 +104,292 @@ describe("inventory profitability selected-object regressions", () => {
expect(reply).toContain("не чистая прибыль компании");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("asks for a period before ranking nomenclature by margin", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1");
expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature");
expect(result?.debug.missing_required_filters).toEqual(["period_from", "period_to"]);
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u043d\u0443\u0436\u0435\u043d \u043f\u0435\u0440\u0438\u043e\u0434");
expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438");
expect(reply).not.toContain("\u041e\u0421");
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
expect(reply).not.toContain("\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u044b\u0439/\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u044b\u0439 \u0441\u043b\u0435\u0434");
expect(reply).not.toContain("settlement");
expect(executeAddressMcpQueryMock).not.toHaveBeenCalled();
});
it("gives a useful accounting limited answer when sales exist but cost is missing", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2020-05-20T00:00:00Z",
Registrator: "Sales document 1",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 1500,
Quantity: 10,
SubcontoKt1: "Item A",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 \u043c\u0430\u0439 2020"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u0440\u0435\u0439\u0442\u0438\u043d\u0433 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440\u044b \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f");
expect(reply).toContain("\u0415\u0441\u0442\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f");
expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438");
expect(reply).toContain("\u0447\u0435\u0441\u0442\u043d\u043e \u043f\u043e\u0441\u0447\u0438\u0442\u0430\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f");
expect(reply).toContain("\u0427\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435");
expect(reply).toContain("90.01 / 90.02");
expect(reply).not.toContain("\u041e\u0421");
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
expect(reply).not.toContain("\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u044b\u0439/\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u044b\u0439 \u0441\u043b\u0435\u0434");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("does not offer to show realizations when only cost base exists in the period", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2017-09-12T00:00:00Z",
Registrator: "Purchase document 1",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 700,
Quantity: 2,
SubcontoDt1: "Item C",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u0430\u044f \u0431\u0430\u0437\u0430");
expect(reply).toContain("\u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e \u043d\u0435\u0439 \u0432 \u043f\u0435\u0440\u0438\u043e\u0434\u0435 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e");
expect(reply).toContain("\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u043e\u0439 \u0431\u0430\u0437\u044b");
expect(reply).not.toContain("\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438");
expect(reply).not.toContain("\u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u0430\u044f/\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u0430\u044f");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("keeps cost-base line drilldown inside the nomenclature margin route", () => {
const marginFollowupContext = {
previous_intent: "inventory_margin_ranking_for_nomenclature" as const,
target_intent: "inventory_margin_ranking_for_nomenclature" as const,
root_intent: "inventory_margin_ranking_for_nomenclature" as const,
previous_filters: {
organization: "OOO Alternative Plus",
period_from: "2017-09-01",
period_to: "2017-09-30"
},
previous_anchor_type: "unknown" as const,
previous_anchor_value: null
};
const result = runAddressDecomposeStage(
"\u043f\u043e\u043a\u0430\u0436\u0438 \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0435\u0431\u0435\u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u043d\u043e\u0439 \u0431\u0430\u0437\u044b",
marginFollowupContext
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30");
expect(result?.filters.missing_required_filters).toEqual([]);
expect(result?.intent.reasons).toContain("intent_adjusted_to_inventory_margin_ranking_followup_context");
});
it("does not pivot margin follow-up account-41 correction into a balance snapshot", () => {
const marginFollowupContext = {
previous_intent: "inventory_margin_ranking_for_nomenclature" as const,
target_intent: "inventory_margin_ranking_for_nomenclature" as const,
root_intent: "inventory_margin_ranking_for_nomenclature" as const,
previous_filters: {
organization: "OOO Alternative Plus",
period_from: "2017-09-01",
period_to: "2017-09-30"
},
previous_anchor_type: "unknown" as const,
previous_anchor_value: null
};
for (const message of [
"\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01",
"\u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e \u0441\u0447\u0435\u0442\u0443 41 \u0432\u043c\u0435\u0441\u0442\u043e \u0441\u0447\u0435\u0442\u0430 01"
]) {
const result = runAddressDecomposeStage(message, marginFollowupContext);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.intent.intent).not.toBe("account_balance_snapshot");
expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30");
expect(result?.filters.missing_required_filters).toEqual([]);
expect(result?.intent.reasons).toContain("intent_adjusted_to_inventory_margin_ranking_followup_context");
}
});
it("keeps carried period when executing margin account-41 correction", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2017-02-10T00:00:00Z",
Registrator: "Purchase document 2017",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 500,
Quantity: 1,
SubcontoDt1: "Item A",
Organization: "OOO Alternative Plus"
},
{
Period: "2017-03-10T00:00:00Z",
Registrator: "Sales document 2017",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 900,
Quantity: 1,
SubcontoKt1: "Item A",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const marginFollowupContext = {
previous_intent: "inventory_margin_ranking_for_nomenclature" as const,
target_intent: "inventory_margin_ranking_for_nomenclature" as const,
root_intent: "inventory_margin_ranking_for_nomenclature" as const,
previous_filters: {
organization: "OOO Alternative Plus",
period_from: "2017-01-01",
period_to: "2017-12-31"
},
previous_anchor_type: "organization" as const,
previous_anchor_value: "OOO Alternative Plus"
};
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01",
{ followupContext: marginFollowupContext }
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1");
expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature");
expect(result?.debug.extracted_filters?.period_from).toBe("2017-01-01");
expect(result?.debug.extracted_filters?.period_to).toBe("2017-12-31");
expect(result?.debug.missing_required_filters).toEqual([]);
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
it("answers period-scoped nomenclature margin ranking with high and low gross-margin buckets", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 4,
matched_rows: 4,
raw_rows: [
{
Period: "2020-01-10T00:00:00Z",
Registrator: "Purchase document 1",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 500,
Quantity: 10,
SubcontoDt1: "Item A",
Organization: "OOO Alternative Plus"
},
{
Period: "2020-02-10T00:00:00Z",
Registrator: "Sales document 1",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 1500,
Quantity: 10,
SubcontoKt1: "Item A",
Organization: "OOO Alternative Plus"
},
{
Period: "2020-03-10T00:00:00Z",
Registrator: "Purchase document 2",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 1000,
Quantity: 5,
SubcontoDt1: "Item B",
Organization: "OOO Alternative Plus"
},
{
Period: "2020-04-10T00:00:00Z",
Registrator: "Sales document 2",
AccountDt: "62.01",
AccountKt: "41.01",
Amount: 900,
Quantity: 5,
SubcontoKt1: "Item B",
Organization: "OOO Alternative Plus"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u041a\u0430\u043a\u0430\u044f \u043d\u043e\u043c\u0435\u043a\u043b\u0430\u0442\u0443\u0440\u0430 \u0442\u043e\u0432\u0430\u0440\u0430 \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u0441 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e \u043a\u0430\u043a\u0430\u044f \u0441 \u043d\u0438\u0437\u043a\u043e\u0439 \u0437\u0430 2020 \u0433\u043e\u0434"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_margin_ranking_for_nomenclature");
expect(result?.debug.selected_recipe).toBe("address_inventory_margin_ranking_for_nomenclature_v1");
expect(result?.debug.capability_id).toBe("inventory_inventory_margin_ranking_for_nomenclature");
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
const reply = String(result?.reply_text ?? "");
expect(reply).toContain("\u0412\u044b\u0441\u043e\u043a\u0430\u044f \u0432\u0430\u043b\u043e\u0432\u0430\u044f \u043c\u0430\u0440\u0436\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c");
expect(reply).toContain("\u041d\u0438\u0437\u043a\u0430\u044f \u0438\u043b\u0438 \u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f");
expect(reply).toContain("Item A");
expect(reply).toContain("Item B");
expect(reply).toContain("\u043d\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b\u044c \u0447\u0438\u0441\u0442\u043e\u0439 \u043f\u0440\u0438\u0431\u044b\u043b\u0438");
expect(reply).not.toContain("\u041e\u0421");
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -587,6 +587,7 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("не подтвержденная клиентская выручка");
expect(reply.text).toContain("Сводка по направлению");
expect(reply.text).toContain("Основание 1С");
expect(reply.text).toContain("По смыслу это скорее финансовый/банковский контур");
expect(reply.text).toContain("вид операции/назначение платежа/договор");
});
@ -613,6 +614,8 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("Сводка по направлению");
expect(reply.text).toContain("Это не обычный поставщик автоматически");
expect(reply.text).toContain("Примеры строк 1С:");
expect(reply.text).toContain("Следующий шаг: могу отдельно разложить назначения платежа");
expect(reply.text).toContain("Показаны первые 5 из 8");
expect(reply.text).not.toContain("00000000007");
});
@ -793,12 +796,11 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Коротко: подтвержденная дебиторская задолженность на 31.05.2020");
expect(reply.text).toContain("Это подтвержденный срез дебиторской задолженности");
expect(reply.text).toContain("Что учтено");
expect(reply.text).toContain("Сводка");
expect(reply.text).toContain("Категории дебиторской задолженности");
expect(reply.text).toContain("Подтвержденные позиции к получению");
expect(reply.text).toContain("Коротко: на 31.05.2020 нам должны");
expect(reply.text).toContain("К получению:");
expect(reply.text).not.toContain("эвристический shortlist");
expect(reply.text).not.toContain("Строк в выборке");
expect(reply.text).not.toContain("Категории дебиторской задолженности");
expect(reply.text).not.toContain("Блок 1");
});
@ -2089,9 +2091,11 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Покрытие VAT-источников в 1С");
expect(reply.text).toContain("Найдено VAT-объектов: 5");
expect(reply.text).toContain("РегистрНакопления.НДСПродажи");
expect(reply.text).toContain("предварительная оценка по бухгалтерским оборотам НДС");
expect(reply.text).toContain("НДС начисленный");
expect(reply.text).not.toContain("Покрытие VAT-источников в 1С");
expect(reply.text).not.toContain("VAT-объектов");
expect(reply.text).not.toContain("РегистрНакопления.НДСПродажи");
});
it("builds confirmed VAT tax-period reply from sales and purchase book markers", () => {
@ -2191,7 +2195,7 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).not.toContain("**1**)");
});
it("keeps VAT probe timestamps intact when numeric emphasis is enabled", () => {
it("keeps VAT probe diagnostics out of the user-facing estimate when numeric emphasis is enabled", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
@ -2226,11 +2230,12 @@ describe("address compose stage utf8 headers", () => {
}
);
expect(reply.text).toContain("последнее движение: 2019-12-31T23:59:59Z");
expect(reply.text).toContain("предварительная оценка по бухгалтерским оборотам НДС");
expect(reply.text).not.toContain("последнее движение: 2019-12-31T23:59:59Z");
expect(reply.text).not.toContain("2019****-12**-31T23:**59**:59Z");
});
it("adds MCP VAT source probe block for confirmed VAT as-of response", () => {
it("hides MCP VAT source probe diagnostics for confirmed VAT as-of response", () => {
const reply = composeFactualReply(
"vat_payable_confirmed_as_of_date",
[
@ -2272,10 +2277,10 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Блок 2.1. Проверка VAT-источников в 1С");
expect(reply.text).toContain("VAT-объектов в метаданных 1С: 3");
expect(reply.text).toContain("Источников с движениями до даты среза: 1");
expect(reply.text).toContain("РегистрНакопления.НДСНачисленный");
expect(reply.text).toContain("Что учтено:");
expect(reply.text).toContain("Подтвержденные позиции:");
expect(reply.text).not.toContain("VAT-объектов");
expect(reply.text).not.toContain("РегистрНакопления.НДСНачисленный");
});
it("adds VAT probe error note for confirmed VAT as-of response", () => {
@ -2305,7 +2310,8 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Дополнительная проверка VAT-источников завершилась ошибкой");
expect(reply.text).toContain("Подтвержденные позиции:");
expect(reply.text).not.toContain("Дополнительная проверка VAT-источников завершилась ошибкой");
});
});
@ -5724,6 +5730,47 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(reply.semantics?.balance_confirmed).toBe(true);
});
it("sorts inventory-on-hand positions by amount before rendering the top list", () => {
const reply = composeFactualReply(
"inventory_on_hand_as_of_date",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "41.01",
account_kt: "",
amount: 148261.67,
analytics: ["Модуль прямоугольный", "Основной склад", "ООО Ромашка"],
item: "Модуль прямоугольный",
warehouse: "Основной склад",
organization: "ООО Ромашка",
quantity: 22
},
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "41.01",
account_kt: "",
amount: 498472.5,
analytics: ["Конструкция трансформер рабочей станции", "Основной склад", "ООО Ромашка"],
item: "Конструкция трансформер рабочей станции",
warehouse: "Основной склад",
organization: "ООО Ромашка",
quantity: 3
}
],
{
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text.indexOf("Конструкция трансформер рабочей станции")).toBeLessThan(
reply.text.indexOf("Модуль прямоугольный")
);
});
it("keeps supplier-overlap reply business-first without exact contour leakage", () => {
const reply = composeFactualReply(
"inventory_supplier_stock_overlap_as_of_date",

View File

@ -329,9 +329,9 @@ describe("address reply builders regressions", () => {
expect(firstLine).toContain("7.271,20");
expect(firstLine).toContain("Авант мебель");
expect(firstLine).not.toContain("3.677.454,14");
expect(result.text).toContain("встречных остатков");
expect(result.text).toContain("Встречная часть");
expect(result.text).toContain("Комитет государственных услуг г. Москвы");
expect(result.text).toContain("Финансовое обеспечение заявки");
expect(result.text).not.toContain("Финансовое обеспечение заявки");
expect(result.text).not.toContain("договор/аналитика: ООО \\Альтернатива Плюс\\");
});
@ -380,7 +380,7 @@ describe("address reply builders regressions", () => {
expect(firstLine).toContain("9.612.904,90");
expect(firstLine).toContain("Департамент капитального ремонта города Москвы.");
expect(firstLine).not.toContain("13.290.359,04");
expect(result.text).toContain("встречных остатков");
expect(result.text).toContain("Встречная часть");
expect(result.text).toContain("Комитет государственных услуг г. Москвы");
});
@ -443,4 +443,140 @@ describe("address reply builders regressions", () => {
expect(result.text.split("\n")[0]).toContain("1.000,00");
expect(result.text).not.toContain("встречных остатков");
});
it("avoids mixed stock quantity as a fake management KPI in on-hand snapshot answers", () => {
const result = composeInventoryReply(
"inventory_on_hand_as_of_date",
[
{
amount: 695360,
quantity: 3,
item: "Рабочая станция",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
period: "2017-06-30",
registrator: "Остатки"
} as any,
{
amount: 295526.51,
quantity: 317000,
item: "Лифлеты",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
period: "2017-06-30",
registrator: "Остатки"
} as any
],
{
userMessage: "какие остатки на июнь 2017",
asOfDate: "2017-06-30"
},
{
resolvePayablesAsOfDate: () => "2017-06-30",
buildInventoryOnHandAggregate: () => [
{
item: "Рабочая станция",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
quantity: 3,
amount: 695360,
operations: 1,
firstPeriod: "2017-06-30",
lastPeriod: "2017-06-30",
sourceRefs: []
},
{
item: "Лифлеты",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
quantity: 317000,
amount: 295526.51,
operations: 1,
firstPeriod: "2017-06-30",
lastPeriod: "2017-06-30",
sourceRefs: []
}
],
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
formatDateRu: (value: string) => value,
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
formatMoneyRub: (value: number) => `${value}`,
isInventoryPurchaseMovement: () => false,
summarizeInventoryTraceRows: () => ({
item: null,
warehouses: [],
organizations: [],
counterparties: [],
documents: [],
firstPeriod: null,
lastPeriod: null,
totalAmount: 0
}),
formatInventoryTraceRows: () => [],
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: () => "",
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: () => false
}
);
expect(result?.text).not.toContain("Суммарное количество");
expect(result?.text).toContain("Общее количество не свожу в один управленческий показатель");
expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список");
});
it("answers cost-base evidence follow-up directly when margin ranking has sales without confirmed cost", () => {
const result = composeInventoryReply(
"inventory_margin_ranking_for_nomenclature",
[
{
amount: 120000,
quantity: 2,
item: "Рабочая станция",
period: "2020-05-20",
registrator: "Реализация"
} as any
],
{
userMessage: "покажи найденные строки себестоимостной базы",
periodFrom: "2020-05-01",
periodTo: "2020-05-31"
},
{
resolvePayablesAsOfDate: () => "2020-05-31",
buildInventoryOnHandAggregate: () => [],
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
formatDateRu: (value: string) => value,
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
formatMoneyRub: (value: number) => `${value}`,
isInventoryPurchaseMovement: () => false,
summarizeInventoryTraceRows: (rows: any[]) => ({
item: rows[0]?.item ?? null,
warehouses: [],
organizations: [],
counterparties: [],
documents: [],
firstPeriod: null,
lastPeriod: null,
totalAmount: 0
}),
formatInventoryTraceRows: () => [],
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: () => "",
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: () => true
}
);
expect(result?.text.split("\n")[0]).toContain(
"подтвержденных строк себестоимостной базы по реализованной номенклатуре не найдено"
);
expect(result?.text).toContain("Есть реализация по 1 номенклатурной позиции");
expect(result?.text).toContain("Строк себестоимости реализации / себестоимостной базы для показа нет");
expect(result?.text).not.toContain("входящих денежных поступлений");
expect(result?.text).not.toContain("амортизац");
});
});

View File

@ -218,6 +218,90 @@ describe("assistant address orchestration runtime adapter", () => {
root_filters: expect.objectContaining({
organization: "Org A"
})
}),
knownOrganizations: ["Org A"]
})
);
});
it("rebuilds business overview period carryover for compact cashflow follow-up when address carryover is absent", async () => {
const runMcpDiscoveryRuntimeEntryPoint = vi.fn(async () => ({
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
entry_status: "bridge_executed",
hot_runtime_wired: false,
discovery_attempted: true
}));
const input = buildInput({
userMessage:
"\u0434\u0430\u0439 \u043e\u0434\u043d\u043e\u0439 \u0441\u0442\u0440\u043e\u043a\u043e\u0439 \u0434\u0435\u043d\u044c\u0433\u0438 \u0431\u0435\u0437 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0438",
sessionItems: [
{
role: "assistant",
debug: {
assistant_mcp_discovery_entry_point_v1: {
turn_input: {
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: "top_desc"
},
turn_meaning_ref: {
explicit_date_scope: "2020",
explicit_organization_scope:
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
}
}
}
}
}
],
runAddressLlmPreDecompose: vi.fn(async () => ({
attempted: true,
applied: false,
effectiveMessage:
"\u0434\u0430\u0439 \u043e\u0434\u043d\u043e\u0439 \u0441\u0442\u0440\u043e\u043a\u043e\u0439 \u0434\u0435\u043d\u044c\u0433\u0438 \u0431\u0435\u0437 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0438",
reason: "raw_kept",
predecomposeContract: {
mode: "unsupported",
intent: "unknown",
period: {}
}
})),
resolveAddressFollowupCarryoverContext: vi.fn(() => null),
resolveAssistantOrchestrationDecision: vi.fn(() => ({
runAddressLane: false,
livingMode: "chat",
livingReason: "unsupported_current_turn_meaning_boundary",
toolGateDecision: "skip_address_lane",
toolGateReason: "unsupported_current_turn_meaning_boundary",
orchestrationContract: {
schema_version: "assistant_orchestration_contract_v1",
assistant_turn_meaning: {
schema_version: "assistant_turn_meaning_v1",
asked_domain_family: "business_summary",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation"
}
}
})),
runMcpDiscoveryRuntimeEntryPoint
});
await buildAssistantAddressOrchestrationRuntime(input);
expect(runMcpDiscoveryRuntimeEntryPoint).toHaveBeenCalledWith(
expect.objectContaining({
followupContext: expect.objectContaining({
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_seeded_ranking_need: "top_desc",
previous_filters: expect.objectContaining({
period_from: "2020-01-01",
period_to: "2020-12-31"
}),
root_filters: expect.objectContaining({
period_from: "2020-01-01",
period_to: "2020-12-31"
})
})
})
);
@ -499,6 +583,70 @@ describe("assistant address orchestration runtime adapter", () => {
);
});
it("prefers raw margin-ranking account correction over account-balance canonical drift", async () => {
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: {
previous_intent: "inventory_margin_ranking_for_nomenclature",
target_intent: "inventory_margin_ranking_for_nomenclature",
root_intent: "inventory_margin_ranking_for_nomenclature",
previous_filters: {
period_from: "2017-01-01",
period_to: "2017-12-31",
organization: "OOO Alternative Plus"
},
previous_anchor_type: "organization",
previous_anchor_value: "OOO Alternative Plus"
}
}));
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
runAddressLane: true,
livingMode: "address_data",
livingReason: "address_lane_triggered",
toolGateDecision: "run_address_lane",
toolGateReason: "followup_context_detected",
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
}));
const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
schema_version: "address_llm_predecompose_contract_v1",
source_message: sourceMessage,
canonical_message: canonicalMessage,
mode: canonicalMessage === sourceMessage ? "unsupported" : "address_query",
intent: canonicalMessage === sourceMessage ? "unknown" : "account_balance_snapshot"
}));
const rawMessage = "\u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e 41 \u0441\u0447\u0435\u0442\u0443 \u0430 \u043d\u0435 01";
const output = await buildAssistantAddressOrchestrationRuntime(
buildInput({
userMessage: rawMessage,
runAddressLlmPreDecompose: vi.fn(async () => ({
attempted: true,
applied: true,
effectiveMessage:
"\u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u043d\u0430\u043b\u0438\u0437 \u043f\u043e \u0441\u0447\u0435\u0442\u0443 41 \u0432\u043c\u0435\u0441\u0442\u043e \u0441\u0447\u0435\u0442\u0430 01",
reason: "normalized_fragment_applied",
predecomposeContract: {
mode: "address_query",
intent: "account_balance_snapshot"
}
})),
buildAddressLlmPredecomposeContractV1,
resolveAddressFollowupCarryoverContext,
resolveAssistantOrchestrationDecision
})
);
expect(output.addressInputMessage).toBe(rawMessage);
expect(output.addressPreDecompose.applied).toBe(false);
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
expect.objectContaining({
rawUserMessage: rawMessage,
effectiveAddressUserMessage: rawMessage
})
);
});
it("prefers raw selected-object inventory action over generic canonical drift intent", async () => {
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: {

View File

@ -66,6 +66,35 @@ describe("assistant capability binding response guard", () => {
expect(output.audit.reason_codes).toContain("capability_binding_guard_clarification_reply");
});
it("uses business clarification for nomenclature margin ranking period", () => {
const output = applyAssistantCapabilityBindingResponseGuard({
assistantReply: "unsafe answer",
replyType: "partial_coverage",
capabilityBinding: binding({
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
capability_contract_id: "inventory_inventory_margin_ranking_for_nomenclature",
binding_status: "blocked",
binding_action: "clarify",
required_anchors: ["period"],
provided_anchors: [],
missing_anchors: ["period"],
requires_focus_object: false,
focus_object_binding_status: "not_required",
result_shape: "nomenclature_margin_ranking",
answer_object_shape: "inventory_margin_ranking",
violations: ["required_anchor_missing"],
reason_codes: ["required_anchor_missing"]
})
});
expect(output.replyType).toBe("partial_coverage");
expect(output.assistantReply).toContain("Для рейтинга прибыльности номенклатуры нужен период");
expect(output.assistantReply).toContain("выручку");
expect(output.assistantReply).toContain("себестоимость реализации");
expect(output.assistantReply).not.toContain("period");
expect(output.audit.applied).toBe(true);
});
it("turns blocked incompatible transitions into bounded replies", () => {
const output = applyAssistantCapabilityBindingResponseGuard({
assistantReply: "unsafe answer",

View File

@ -130,6 +130,34 @@ describe("assistant capability runtime binding adapter", () => {
expect(binding.violations).toEqual([]);
});
it("treats period_from and period_to as satisfying period anchor", () => {
const binding = resolveAssistantCapabilityRuntimeBinding({
addressDebug: {
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
detected_intent: "inventory_margin_ranking_for_nomenclature",
detected_mode: "address_query",
capability_layer: "compute",
capability_route_mode: "exact",
extracted_filters: {
period_from: "2020-05-01",
period_to: "2020-05-31",
organization: "ООО Альтернатива Плюс"
},
rows_matched: 1,
route_expectation_status: "matched"
},
groundingStatus: "grounded",
replyType: "factual"
});
expect(binding.binding_status).toBe("bound");
expect(binding.binding_action).toBe("allow");
expect(binding.required_anchors).toEqual(["period"]);
expect(binding.provided_anchors).toEqual(expect.arrayContaining(["period_from", "period_to"]));
expect(binding.missing_anchors).toEqual([]);
expect(binding.violations).toEqual([]);
});
it("blocks selected-object capabilities when required anchors are missing", () => {
const binding = resolveAssistantCapabilityRuntimeBinding({
addressDebug: {

View File

@ -60,6 +60,25 @@ describe("assistant MCP discovery data need graph", () => {
);
});
it("does not turn explicit no-top wording into a value-flow ranking", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "counterparty value-flow evidence",
rawUtterance:
"Определить общую сумму поступлений в ООО Альтернатива Плюс за 2020 год, исключая топ-контрагентов и детализацию по контрагентам",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "counterparty_value_or_turnover",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
}
});
expect(result.business_fact_family).toBe("value_flow");
expect(result.ranking_need).toBeNull();
expect(result.decomposition_candidates).toContain("aggregate_checked_amounts");
expect(result.decomposition_candidates).not.toContain("aggregate_ranked_axis_values");
});
it("marks metadata lane choice as a clarification-required graph", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "metadata lane clarification",

View File

@ -15,11 +15,26 @@ function entryPointContract(overrides: Record<string, unknown> = {}) {
business_fact_answer_allowed: true,
requires_user_clarification: false,
planner: {
selected_chain_id: "value_flow_ranking",
catalog_chain_template_matches: ["value_flow_ranking", "value_flow"],
selected_chain_id: "value_flow",
evidence_plan: {
schema_version: "assistant_evidence_planner_v1",
policy_owner: "assistantEvidencePlanner",
planner_status: "ready_for_execution",
selected_chain_id: "value_flow",
evidence_axes: {
missing_axes: []
},
coverage_gate: {
expected_coverage: "confirmed_coverage"
},
answer_contract: {
answer_mode: "confirmed_business_answer"
}
},
catalog_chain_template_matches: ["value_flow"],
catalog_chain_template_alignment: {
alignment_status: "selected_matches_top",
top_chain_template_match: "value_flow_ranking",
top_chain_template_match: "value_flow",
selected_chain_template_rank: 1,
selected_chain_is_catalog_template: true,
selected_chain_in_catalog_matches: true,
@ -30,13 +45,16 @@ function entryPointContract(overrides: Record<string, unknown> = {}) {
schema_version: "assistant_mcp_route_candidate_v1",
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
candidate_status: "ready_for_reviewed_execution",
selected_chain_id: "value_flow_ranking",
selected_chain_summary: "Rank value flow",
nearest_catalog_chain_template: "value_flow_ranking",
selected_chain_id: "value_flow",
selected_chain_summary: "Measure value flow",
nearest_catalog_chain_template: "value_flow",
catalog_alignment_status: "selected_matches_top",
business_fact_family: "value_flow",
action_family: "turnover",
proof_expectation: "coverage_checked_fact",
evidence_plan_status: "ready_for_execution",
evidence_answer_mode: "confirmed_business_answer",
evidence_expected_coverage: "confirmed_coverage",
required_axes: ["organization", "period"],
provided_axes: ["organization", "period"],
missing_axes: [],
@ -45,6 +63,22 @@ function entryPointContract(overrides: Record<string, unknown> = {}) {
recommended_next_action: "Execute through the reviewed runtime bridge and truth gate.",
forbidden_overclaim_flags: ["no_unchecked_fact_totals"]
},
execution_handoff: {
schema_version: "assistant_mcp_discovery_execution_handoff_v1",
policy_owner: "assistantMcpDiscoveryExecutionHandoff",
handoff_status: "ready_for_guarded_response",
selected_chain_id: "value_flow",
route_candidate_status: "ready_for_reviewed_execution",
evidence_answer_mode: "confirmed_business_answer",
evidence_expected_coverage: "confirmed_coverage",
pilot_status: "executed",
answer_mode: "confirmed_with_bounded_inference",
mcp_execution_performed: true,
allowed_hot_chain: true,
can_use_guarded_response: true,
must_keep_internal_mechanics_hidden: true,
reason_codes: ["execution_handoff_guarded_response_ready"]
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
}
@ -66,22 +100,32 @@ describe("assistant MCP discovery debug attachment", () => {
);
expect(debug.mcp_discovery_entry_status).toBe("bridge_executed");
expect(debug.mcp_discovery_attempted).toBe(true);
expect(debug.mcp_discovery_hot_runtime_wired).toBe(false);
expect(debug.mcp_discovery_hot_runtime_wired).toBe(true);
expect(debug.mcp_discovery_bridge_status).toBe("answer_draft_ready");
expect(debug.mcp_discovery_selected_chain_id).toBe("value_flow_ranking");
expect(debug.mcp_discovery_catalog_chain_template_matches).toEqual(["value_flow_ranking", "value_flow"]);
expect(debug.mcp_discovery_selected_chain_id).toBe("value_flow");
expect(debug.mcp_discovery_evidence_plan_status).toBe("ready_for_execution");
expect(debug.mcp_discovery_evidence_plan_answer_mode).toBe("confirmed_business_answer");
expect(debug.mcp_discovery_evidence_plan_expected_coverage).toBe("confirmed_coverage");
expect(debug.mcp_discovery_evidence_plan_missing_axes).toEqual([]);
expect(debug.mcp_discovery_catalog_chain_template_matches).toEqual(["value_flow"]);
expect(debug.mcp_discovery_catalog_chain_alignment_status).toBe("selected_matches_top");
expect(debug.mcp_discovery_catalog_chain_top_match).toBe("value_flow_ranking");
expect(debug.mcp_discovery_catalog_chain_top_match).toBe("value_flow");
expect(debug.mcp_discovery_catalog_chain_selected_matches_top).toBe(true);
expect(debug.mcp_discovery_route_candidate_status).toBe("ready_for_reviewed_execution");
expect(debug.mcp_discovery_route_candidate_fact_family).toBe("value_flow");
expect(debug.mcp_discovery_route_candidate_action_family).toBe("turnover");
expect(debug.mcp_discovery_route_candidate_evidence_plan_status).toBe("ready_for_execution");
expect(debug.mcp_discovery_route_candidate_evidence_answer_mode).toBe("confirmed_business_answer");
expect(debug.mcp_discovery_route_candidate_evidence_expected_coverage).toBe("confirmed_coverage");
expect(debug.mcp_discovery_route_candidate_missing_axes).toEqual([]);
expect(debug.mcp_discovery_route_candidate_provided_axes).toEqual(["organization", "period"]);
expect(debug.mcp_discovery_route_candidate_executable_now).toBe(true);
expect(debug.mcp_discovery_route_candidate_next_action).toBe(
"Execute through the reviewed runtime bridge and truth gate."
);
expect(debug.mcp_discovery_execution_handoff_status).toBe("ready_for_guarded_response");
expect(debug.mcp_discovery_execution_handoff_allowed_hot_chain).toBe(true);
expect(debug.mcp_discovery_execution_handoff_can_use_guarded_response).toBe(true);
expect(debug.mcp_discovery_answer_mode).toBe("confirmed_with_bounded_inference");
expect(debug.mcp_discovery_business_fact_answer_allowed).toBe(true);
expect(debug.mcp_discovery_user_facing_response_allowed).toBe(true);
@ -100,15 +144,27 @@ describe("assistant MCP discovery debug attachment", () => {
expect(debug.mcp_discovery_hot_runtime_wired).toBe(false);
expect(debug.mcp_discovery_bridge_status).toBeNull();
expect(debug.mcp_discovery_selected_chain_id).toBeNull();
expect(debug.mcp_discovery_evidence_plan_v1).toBeNull();
expect(debug.mcp_discovery_evidence_plan_status).toBeNull();
expect(debug.mcp_discovery_evidence_plan_answer_mode).toBeNull();
expect(debug.mcp_discovery_evidence_plan_expected_coverage).toBeNull();
expect(debug.mcp_discovery_evidence_plan_missing_axes).toEqual([]);
expect(debug.mcp_discovery_catalog_chain_template_matches).toEqual([]);
expect(debug.mcp_discovery_catalog_chain_alignment_status).toBeNull();
expect(debug.mcp_discovery_catalog_chain_top_match).toBeNull();
expect(debug.mcp_discovery_catalog_chain_selected_matches_top).toBe(false);
expect(debug.mcp_discovery_route_candidate_v1).toBeNull();
expect(debug.mcp_discovery_route_candidate_status).toBeNull();
expect(debug.mcp_discovery_route_candidate_evidence_plan_status).toBeNull();
expect(debug.mcp_discovery_route_candidate_evidence_answer_mode).toBeNull();
expect(debug.mcp_discovery_route_candidate_evidence_expected_coverage).toBeNull();
expect(debug.mcp_discovery_route_candidate_missing_axes).toEqual([]);
expect(debug.mcp_discovery_route_candidate_provided_axes).toEqual([]);
expect(debug.mcp_discovery_route_candidate_executable_now).toBe(false);
expect(debug.mcp_discovery_execution_handoff_v1).toBeNull();
expect(debug.mcp_discovery_execution_handoff_status).toBeNull();
expect(debug.mcp_discovery_execution_handoff_allowed_hot_chain).toBe(false);
expect(debug.mcp_discovery_execution_handoff_can_use_guarded_response).toBe(false);
expect(debug.mcp_discovery_answer_mode).toBeNull();
expect(debug.mcp_discovery_business_fact_answer_allowed).toBe(false);
expect(debug.mcp_discovery_user_facing_response_allowed).toBe(false);

View File

@ -1750,6 +1750,51 @@ describe("assistant MCP discovery pilot executor", () => {
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2);
});
it("prefers clean counterparty candidates for bidirectional net value-flow probes", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: ["деньгам с группа свк", "Группа СВК"],
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
}
});
const deps = buildSequentialDeps([
{
rows: [{ Period: "2020-06-30T00:00:00", Amount: 12093465, Counterparty: "Группа СВК" }]
},
{
rows: []
}
]);
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
expect(result.derived_bidirectional_value_flow).toMatchObject({
counterparty: "Группа СВК",
period_scope: "2020",
net_amount: 12093465,
net_direction: "net_incoming",
incoming_customer_revenue: {
rows_matched: 1,
total_amount: 12093465
},
outgoing_supplier_payout: {
rows_matched: 0,
total_amount: 0
}
});
expect(result.reason_codes).toContain("pilot_derived_bidirectional_value_flow_from_confirmed_rows");
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2);
const serializedCalls = deps.executeAddressMcpQuery.mock.calls
.map((call) => JSON.stringify(call[0]))
.join("\n");
expect(serializedCalls).toContain("%Группа%");
expect(serializedCalls).toContain("%СВК%");
expect(serializedCalls).not.toContain("деньгам с группа свк");
});
it("preserves explicit date ranges when building bidirectional value-flow probes", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {

View File

@ -46,6 +46,35 @@ describe("assistant MCP discovery planner", () => {
expect(result.required_axes).toEqual(["counterparty", "period", "aggregate_axis", "amount", "coverage_target"]);
expect(result.catalog_review.review_status).toBe("catalog_compatible");
expect(result.discovery_plan.answer_may_use_raw_model_claims).toBe(false);
expect(result.evidence_plan).toMatchObject({
schema_version: "assistant_evidence_planner_v1",
planner_status: "ready_for_execution",
selected_chain_id: "value_flow",
data_need: {
business_fact_family: "value_flow",
action_family: "turnover",
proof_expectation: "coverage_checked_fact"
},
coverage_gate: {
expected_coverage: "confirmed_coverage",
answer_permission_if_satisfied: "confirmed_business_answer",
answer_may_use_raw_model_claims: false
},
answer_contract: {
answer_mode: "confirmed_business_answer",
must_keep_internal_mechanics_hidden: true
}
});
expect(result.evidence_plan.evidence_axes.required_axes).toEqual([
"counterparty",
"period",
"aggregate_axis",
"amount",
"coverage_target"
]);
expect(result.evidence_plan.evidence_axes.provided_axes).toEqual(["counterparty", "business_entity", "period"]);
expect(result.evidence_plan.evidence_axes.missing_axes).toEqual(["aggregate_axis", "amount", "coverage_target"]);
expect(result.evidence_plan.evidence_axes.user_actionable_missing_axes).toEqual([]);
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.catalog_chain_template_matches[0]).toBe("value_flow");
expect(result.catalog_chain_template_alignment).toMatchObject({
@ -284,6 +313,17 @@ describe("assistant MCP discovery planner", () => {
selected_chain_matches_top: true
});
expect(result.catalog_review.review_status).toBe("catalog_compatible");
expect(result.evidence_plan.coverage_gate.expected_coverage).toBe("bounded_inference");
expect(result.evidence_plan.answer_contract.answer_mode).toBe("bounded_business_inference");
expect(result.evidence_plan.answer_contract.required_user_layers).toEqual([
"business_conclusion",
"key_figures",
"evidence_boundary",
"next_step"
]);
expect(result.evidence_plan.answer_contract.forbidden_overclaim_flags).toContain(
"no_unchecked_business_health_claim"
);
expect(result.reason_codes).toContain("planner_selected_business_overview_from_data_need_graph");
expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_business_overview");
});

View File

@ -29,6 +29,45 @@ function entryPoint(overrides: Record<string, unknown> = {}) {
} as any;
}
function hotValueFlowEntryPoint(overrides: Record<string, unknown> = {}) {
const base = entryPoint({
hot_runtime_wired: true,
bridge: {
bridge_status: "answer_draft_ready",
hot_runtime_wired: true,
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
execution_handoff: {
schema_version: "assistant_mcp_discovery_execution_handoff_v1",
policy_owner: "assistantMcpDiscoveryExecutionHandoff",
handoff_status: "ready_for_guarded_response",
selected_chain_id: "value_flow",
route_candidate_status: "ready_for_reviewed_execution",
evidence_answer_mode: "confirmed_business_answer",
evidence_expected_coverage: "confirmed_coverage",
pilot_status: "executed",
answer_mode: "confirmed_with_bounded_inference",
mcp_execution_performed: true,
allowed_hot_chain: true,
can_use_guarded_response: true,
must_keep_internal_mechanics_hidden: true,
reason_codes: ["execution_handoff_guarded_response_ready"]
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Confirmed value flow.",
confirmed_lines: ["1C value-flow rows were found for counterparty SVK", "Confirmed scoped amount: 3 750 руб."],
inference_lines: ["Counterparty value-flow total was calculated from confirmed 1C movement rows"],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
});
return { ...base, ...overrides } as any;
}
describe("assistant MCP discovery response candidate", () => {
it("builds a Russian guarded candidate from a confirmed discovery draft", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(entryPoint());
@ -44,6 +83,16 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("primitive");
});
it("marks an allowlisted value-flow handoff as hot-wired for guarded runtime use", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(hotValueFlowEntryPoint());
expect(candidate.candidate_status).toBe("ready_for_guarded_use");
expect(candidate.hot_runtime_wired).toBe(true);
expect(candidate.eligible_for_future_hot_runtime).toBe(true);
expect(candidate.reason_codes).toContain("mcp_discovery_response_candidate_guarded_hot_wired");
expect(candidate.reply_text).toContain("3 750");
});
it("keeps inventory reserve boundary answers direct instead of compacting into a money overview", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -213,6 +262,62 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("маржа к выручке 90.01");
});
it("answers direct net-profit questions with the accounting result first instead of saying no", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
raw_message: "какая чистая прибыль по Альтернативе за 2020?",
effective_message: "Определить чистую прибыль компании Альтернатива за 2020 год",
asked_domain_family: "business_overview",
asked_action_family: "profit_margin_boundary",
unsupported_but_understood_family: "profit_margin_boundary",
explicit_date_scope: "2020"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
accounting_financial_result: {
period_scope: "2020",
final_result_direction: "loss",
final_result_amount_human_ru: "7 136 815,85 руб.",
net_margin_to_revenue_pct: -59.41
}
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Коротко: по бухгалтерскому маршруту 90/91/99 за 2020 подтвержден учетный убыток.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
const firstLine = String(candidate.reply_text ?? "").split("\n")[0] ?? "";
expect(firstLine).toContain("за 2020 по закрытию счетов 90/91/99");
expect(firstLine).toContain("учетный убыток");
expect(firstLine).toContain("минус 7 136 815,85 руб.");
expect(firstLine).not.toContain("нет, денежное операционное нетто");
expect(candidate.reply_text).toContain("не денежный поток");
});
it("keeps vendor-risk boundary answers direct instead of compacting into a money overview", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -446,11 +551,11 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности");
expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль");
expect(candidate.reply_text).toContain("Годовое операционное нетто в широком срезе не ранжирую");
expect(candidate.reply_text).toContain("проверка достигла лимита строк");
expect(candidate.reply_text).toContain("выбрать конкретный год или квартал для дозапроса");
expect(candidate.reply_text).toContain("без выдачи непроверенного итога");
expect(candidate.reply_text).toContain("лучше разбить проверку по годам или кварталам");
expect(candidate.reply_text).toContain("не считаю полным бухгалтерским оборотом");
expect(candidate.reply_text).not.toContain("По расчетному операционному нетто лучший год");
expect(candidate.reply_text).not.toContain("По годам:");
expect(candidate.reply_text).not.toContain("проверка достигла лимита строк");
expect(candidate.reply_text).not.toContain("лимит выборки MCP");
expect(candidate.reply_text).not.toContain("MCP-срез");
expect(candidate.reply_text).not.toContain("Что подтверждено:");
@ -529,6 +634,150 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("Складской срез");
});
it("lets compact cashflow wording override profit-boundary and overview prose", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
raw_message:
"\u0421\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0430 \u0437\u0430 2020 \u0433\u043e\u0434? \u041e\u0442\u0432\u0435\u0442\u044c \u043a\u043e\u0440\u043e\u0442\u043a\u043e: \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438, \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e, \u044d\u0442\u043e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438\u043b\u0438 \u043d\u0435\u0442.",
asked_action_family: "profit_margin_boundary",
unsupported_but_understood_family: "profit_margin_boundary",
explicit_organization_scope:
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
explicit_date_scope: "2020"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: [
"data_need_graph_family_business_overview",
"data_need_graph_business_overview_direct_money_answer"
]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
period_scope: "2020",
incoming_customer_revenue: {
total_amount_human_ru: "47 628 853,03 руб."
},
outgoing_supplier_payout: {
total_amount_human_ru: "43 763 351,53 руб."
},
net_amount_human_ru: "3 865 501,50 руб.",
net_direction: "net_incoming",
top_customers: [{ axis_value: "СБЕРБАНК", total_amount_human_ru: "12 792 194,31 руб." }],
top_suppliers: [],
accounting_financial_result: {
final_result_direction: "loss",
final_result_amount_human_ru: "7 136 815,85 руб.",
period_scope: "2020"
}
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "wide overview should not leak",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("47 628 853,03");
expect(candidate.reply_text).toContain("43 763 351,53");
expect(candidate.reply_text).toContain("3 865 501,50");
expect(candidate.reply_text).not.toContain("СБЕРБАНК");
expect(candidate.reply_text).not.toContain("7 136 815,85");
expect(candidate.reply_text).not.toContain("wide overview");
});
it("uses compact cashflow output for one-line no-breakdown wording", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
raw_message:
"\u0434\u0430\u0439 \u043e\u0434\u043d\u043e\u0439 \u0441\u0442\u0440\u043e\u043a\u043e\u0439 \u0434\u0435\u043d\u044c\u0433\u0438 \u0431\u0435\u0437 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0438",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_date_scope: "2020"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: [
"data_need_graph_family_business_overview",
"data_need_graph_business_overview_direct_money_answer"
]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
period_scope: "2020",
incoming_customer_revenue: {
total_amount_human_ru: "47 628 853,03 \u0440\u0443\u0431."
},
outgoing_supplier_payout: {
total_amount_human_ru: "43 763 351,53 \u0440\u0443\u0431."
},
net_amount_human_ru: "3 865 501,50 \u0440\u0443\u0431.",
net_direction: "net_incoming",
top_customers: [
{
axis_value: "\u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a, \u041f\u0410\u041e",
total_amount_human_ru: "12 792 194,31 \u0440\u0443\u0431."
}
],
top_suppliers: [
{
axis_value: "\u0414\u0435\u043f\u0430\u0440\u0442\u0430\u043c\u0435\u043d\u0442",
total_amount_human_ru: "9 612 904,90 \u0440\u0443\u0431."
}
]
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "wide overview should not leak",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: "\u0427\u0442\u043e \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u0434\u0430\u043b\u044c\u0448\u0435"
}
}
})
);
expect(candidate.reply_text).toContain("\u0437\u0430 2020");
expect(candidate.reply_text).toContain("47 628 853,03");
expect(candidate.reply_text).toContain("43 763 351,53");
expect(candidate.reply_text).toContain("3 865 501,50");
expect(candidate.reply_text).not.toContain("\u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a");
expect(candidate.reply_text).not.toContain("\u0427\u0442\u043e \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c");
expect(candidate.reply_text).not.toContain("wide overview");
});
it("labels organization-scoped bidirectional value-flow continuations as company scope", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({

View File

@ -135,6 +135,42 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate");
});
it("keeps inventory margin-ranking exact reply over stale value-flow discovery candidate", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply:
"За период 2017 рейтинг прибыльности номенклатуры построить нельзя: нет подтвержденной себестоимости реализации.",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "partial_coverage",
addressRuntimeMeta: {
detected_intent: "inventory_margin_ranking_for_nomenclature",
selected_recipe: "address_inventory_margin_ranking_for_nomenclature_v1",
capability_id: "inventory_inventory_margin_ranking_for_nomenclature",
assistant_mcp_discovery_entry_point_v1: entryPoint({
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover"
},
data_need_graph: {
business_fact_family: "value_flow",
clarification_gaps: []
}
}
})
}
});
expect(result.applied).toBe(false);
expect(result.decision).toBe("keep_current_reply");
expect(result.reply_text).toContain("рейтинг прибыльности номенклатуры");
expect(result.reason_codes).toContain(
"mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply"
);
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied");
});
it("lets a grounded business overview candidate override a semantically wrong exact address recipe", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "Supplier and stock overlap was confirmed for 2020.",

View File

@ -71,6 +71,11 @@ describe("assistant MCP discovery runtime bridge", () => {
expect(result.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.user_facing_response_allowed).toBe(true);
expect(result.execution_handoff).toMatchObject({
handoff_status: "not_enabled_for_chain",
allowed_hot_chain: false,
can_use_guarded_response: false
});
expect(result.reason_codes).toContain("runtime_bridge_not_wired_to_hot_assistant_answer");
});
@ -88,6 +93,10 @@ describe("assistant MCP discovery runtime bridge", () => {
expect(result.requires_user_clarification).toBe(true);
expect(result.pilot.mcp_execution_performed).toBe(false);
expect(result.business_fact_answer_allowed).toBe(false);
expect(result.execution_handoff).toMatchObject({
handoff_status: "awaiting_user_scope",
can_use_guarded_response: false
});
expect(result.answer_draft.next_step_line).toContain("Уточните контрагента");
});
@ -163,6 +172,13 @@ describe("assistant MCP discovery runtime bridge", () => {
});
expect(result.loop_state.pending_axes).toContain("organization");
expect(result.loop_state.provided_axes).toContain("aggregate_axis");
expect(result.planner.evidence_plan.evidence_axes.user_actionable_missing_axes).toEqual(["organization"]);
expect(result.planner.evidence_plan.evidence_axes.missing_axes).toEqual([
"organization",
"aggregate_axis",
"amount",
"coverage_target"
]);
expect(result.loop_state.catalog_chain_template_matches[0]).toBe("value_flow_ranking");
expect(result.loop_state.catalog_chain_template_alignment.alignment_status).toBe("selected_matches_top");
expect(result.loop_state.catalog_chain_template_alignment.selected_chain_matches_top).toBe(true);
@ -174,10 +190,15 @@ describe("assistant MCP discovery runtime bridge", () => {
catalog_alignment_status: "selected_matches_top",
business_fact_family: "value_flow",
action_family: "turnover",
evidence_plan_status: "needs_clarification",
evidence_answer_mode: "clarification_required",
evidence_expected_coverage: "confirmed_coverage",
executable_now: false
});
expect(result.route_candidate.missing_axes).toContain("organization");
expect(result.route_candidate.provided_axes).toContain("aggregate_axis");
expect(result.route_candidate.missing_axes).not.toContain("amount");
expect(result.route_candidate.missing_axes).not.toContain("coverage_target");
expect(result.route_candidate.recommended_next_action).toBe(
"Ask the user for the missing scope axes before MCP execution."
);
@ -230,6 +251,11 @@ describe("assistant MCP discovery runtime bridge", () => {
executable_now: true,
enablement_reason: null
});
expect(result.execution_handoff).toMatchObject({
handoff_status: "not_enabled_for_chain",
allowed_hot_chain: false,
can_use_guarded_response: false
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("СВК-А");
});
@ -1053,6 +1079,19 @@ describe("assistant MCP discovery runtime bridge", () => {
period_scope: "2020",
total_amount: 5000
});
expect(result.execution_handoff).toMatchObject({
schema_version: "assistant_mcp_discovery_execution_handoff_v1",
handoff_status: "ready_for_guarded_response",
selected_chain_id: "value_flow",
route_candidate_status: "ready_for_reviewed_execution",
pilot_status: "executed",
mcp_execution_performed: true,
allowed_hot_chain: true,
can_use_guarded_response: true,
must_keep_internal_mechanics_hidden: true
});
expect(result.reason_codes).toContain("runtime_bridge_execution_handoff_ready_for_guarded_response");
expect(result.reason_codes).toContain("execution_handoff_guarded_response_ready");
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("5 000");
expect(result.answer_draft.confirmed_lines.join("\n")).not.toContain("контрагенту");
});

View File

@ -126,7 +126,10 @@ describe("assistant MCP discovery runtime entry point", () => {
expect(result.bridge?.bridge_status).toBe("answer_draft_ready");
expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_value_flow_query_movements_v1");
expect(result.bridge?.pilot.derived_value_flow?.total_amount).toBe(3750);
expect(result.bridge?.hot_runtime_wired).toBe(false);
expect(result.bridge?.hot_runtime_wired).toBe(true);
expect(result.bridge?.execution_handoff.can_use_guarded_response).toBe(true);
expect(result.hot_runtime_wired).toBe(true);
expect(result.reason_codes).toContain("runtime_entry_point_wired_to_guarded_hot_assistant_answer");
expect(result.reason_codes).toContain("mcp_discovery_unsupported_but_understood_turn");
});

View File

@ -1789,6 +1789,56 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_predecompose");
});
it("grounds organization aliases from known organizations for clean-session earnings questions", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "скока денег альтернатива заработала за 20 год?",
effectiveMessage: "скока денег альтернатива заработала за 20 год?",
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
predecomposeContract: {
period: {
period_from: "2020-01-01",
period_to: "2020-12-31",
has_explicit_period: true
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref?.explicit_organization_scope).toBe("ООО Альтернатива Плюс");
expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2020");
expect(result.reason_codes).toContain("mcp_discovery_organization_scope_from_known_organizations");
expect(result.data_need_graph?.clarification_gaps ?? []).not.toContain("organization");
});
it("treats a business-overview company alias misread as counterparty as organization scope", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "Сколько денег Альтернатива заработала за 2020 год?",
effectiveMessage: "Определить финансовый результат компании Альтернатива за 2020 год",
assistantTurnMeaning: {
asked_domain_family: "business_summary",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation"
},
predecomposeContract: {
entities: { counterparty: "Альтернатива", organization: null },
period: {
period_from: "2020-01-01",
period_to: "2020-12-31",
has_explicit_period: true
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref?.explicit_organization_scope).toBe("Альтернатива");
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.data_need_graph?.clarification_gaps ?? []).not.toContain("organization");
expect(result.reason_codes).toContain("mcp_discovery_counterparty_reinterpreted_as_organization_scope");
});
it("keeps all-time business overview from reusing a negated VAT period as active scope", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
@ -2308,6 +2358,69 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_business_overview_direct_money_answer");
});
it("does not inherit ranking when a cashflow follow-up explicitly says no overview or tops", () => {
const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u043d\u0435 \u043e\u0431\u0437\u043e\u0440, \u043f\u0440\u043e\u0441\u0442\u043e \u0434\u0435\u043d\u044c\u0433\u0438: \u043f\u0440\u0438\u0448\u043b\u043e, \u0443\u0448\u043b\u043e, \u043d\u0435\u0442\u0442\u043e \u0437\u0430 2020 \u0431\u0435\u0437 \u0442\u043e\u043f\u043e\u0432",
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName
},
previous_seeded_ranking_need: "top_desc"
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.data_need_graph?.ranking_need).toBeNull();
expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2020");
expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_business_overview_direct_money_answer");
});
it("preserves previous overview period for compact cashflow display follow-ups", () => {
const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0434\u0430\u0439 \u043e\u0434\u043d\u043e\u0439 \u0441\u0442\u0440\u043e\u043a\u043e\u0439 \u0434\u0435\u043d\u044c\u0433\u0438 \u0431\u0435\u0437 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0438",
assistantTurnMeaning: {
asked_domain_family: "business_summary",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true,
reason_codes: ["compact_cashflow_display_current_turn_signal"]
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
},
previous_seeded_ranking_need: "top_desc"
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.data_need_graph?.ranking_need).toBeNull();
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context");
expect(result.reason_codes).not.toContain("mcp_discovery_topic_switch_suppressed_followup_scope");
expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_business_overview_direct_money_answer");
expect(result.data_need_graph?.reason_codes).not.toContain("data_need_graph_all_time_scope_hint");
});
it("routes organization-level profit and margin wording to business overview instead of exact value recipes", () => {
const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
@ -3342,6 +3455,107 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context");
});
it("pivots a business-overview follow-up with explicit counterparty net-flow wording back to counterparty value flow", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
assistantTurnMeaning: {
asked_domain_family: "unknown",
asked_action_family: "unknown"
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.data_need_graph?.subject_candidates).toEqual([counterpartyName]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: [counterpartyName],
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain(
"mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow"
);
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_raw_scope");
expect(result.reason_codes).not.toContain("mcp_discovery_broad_business_evaluation_route_candidate");
expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty");
});
it("survives poisoned business-overview turn meaning when predecompose still carries the counterparty", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
effectiveMessage:
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0443\u043c\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432, \u0441\u0443\u043c\u043c\u0443 \u0432\u044b\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432 \u0438 \u0447\u0438\u0441\u0442\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c (\u043d\u0435\u0442\u0442\u043e) \u0434\u043b\u044f \u0433\u0440\u0443\u043f\u043f\u044b \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0439 \u0421\u0412\u041a \u0437\u0430 2020 \u0433\u043e\u0434.",
assistantTurnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "profit_margin_boundary",
explicit_date_scope: "2020",
unsupported_but_understood_family: "profit_margin_boundary",
stale_replay_forbidden: true
},
predecomposeContract: {
entities: {
counterparty: counterpartyName
},
period: {
scope: "range",
period_from: "2020-01-01",
period_to: "2020-12-31"
}
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.data_need_graph?.subject_candidates).toEqual([counterpartyName]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: [counterpartyName],
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain(
"mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow"
);
expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_predecompose");
expect(result.reason_codes).not.toContain("mcp_discovery_broad_business_evaluation_route_candidate");
});
it("lets an explicit VAT follow-up stay on the exact VAT route instead of stale business overview", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";

View File

@ -183,6 +183,75 @@ describe("assistantRoutePolicy", () => {
expect(decision.livingMode).toBe("address_data");
});
it("keeps margin-ranking found-rows follow-up in address lane", () => {
const policy = buildPolicy();
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "покажи найденные строки себестоимостной базы",
effectiveAddressUserMessage: "покажи найденные строки себестоимостной базы",
followupContext: {
previous_intent: "inventory_margin_ranking_for_nomenclature",
root_intent: "inventory_margin_ranking_for_nomenclature",
previous_filters: {
organization: "ООО Альтернатива Плюс",
period_from: "2020-05-01",
period_to: "2020-05-31"
}
},
llmPreDecomposeMeta: {
applied: false,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
},
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.orchestrationContract?.hard_meta_mode).toBe(null);
expect(decision.orchestrationContract?.final_decision?.tool_gate_reason).toBe("followup_context_detected");
});
it("keeps margin-ranking period expansion follow-up in address lane", () => {
const policy = buildPolicy();
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "расширь до 2017 года",
effectiveAddressUserMessage: "расширь до 2017 года",
followupContext: {
previous_intent: "inventory_margin_ranking_for_nomenclature",
root_intent: "inventory_margin_ranking_for_nomenclature",
previous_filters: {
organization: "ООО Альтернатива Плюс",
period_from: "2020-05-01",
period_to: "2020-05-31"
}
},
llmPreDecomposeMeta: {
applied: false,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
},
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.orchestrationContract?.hard_meta_mode).toBe(null);
});
it("does not let deep session continuation override an exact VAT period route", () => {
const policy = buildPolicy({
detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }),

View File

@ -57,6 +57,34 @@ describe("assistantTurnMeaningPolicy", () => {
]);
});
it("keeps counterparty net-flow wording in counterparty semantics even if effective text mentions profit", () => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "unknown", confidence: "low" })
});
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage:
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
effectiveAddressUserMessage:
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0443\u043c\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432, \u0441\u0443\u043c\u043c\u0443 \u0432\u044b\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432 \u0438 \u0447\u0438\u0441\u0442\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c (\u043d\u0435\u0442\u0442\u043e) \u0434\u043b\u044f \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020 \u0433\u043e\u0434."
});
expect(meaning.explicit_intent_candidate).toBeNull();
expect(meaning.asked_domain_family).toBe("counterparty_value");
expect(meaning.asked_action_family).toBe("net_value_flow");
expect(meaning.unsupported_but_understood_family).toBe("counterparty_bidirectional_value_flow_or_netting");
expect(meaning.explicit_entity_candidates).toEqual([
{
type: "counterparty",
value: "\u0433\u0440\u0443\u043f\u043f\u0430 \u0441\u0432\u043a",
source: "current_turn_loose_entity_tail"
}
]);
expect(meaning.reason_codes).toContain("counterparty_bidirectional_value_flow_current_turn_signal");
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
expect(meaning.stale_replay_forbidden).toBe(true);
});
it("ignores temporal tail words in all-time revenue ranking questions", () => {
const policy = buildPolicy({
resolveAddressIntent: (text: string) =>
@ -165,6 +193,55 @@ describe("assistantTurnMeaningPolicy", () => {
expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal");
});
it("treats no-tops compact money wording as display modifier, not counterparty exclusion", () => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "customer_revenue_and_payments", confidence: "high" })
});
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage:
"\u0430 \u0435\u0441\u043b\u0438 \u043a\u043e\u0440\u043e\u0442\u043a\u043e, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438 \u0434\u0435\u043d\u044c\u0433\u0430\u043c\u0438 \u0431\u0435\u0437 \u0442\u043e\u043f\u043e\u0432?",
effectiveAddressUserMessage:
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0438\u0442\u043e\u0433\u043e\u0432\u044b\u0439 \u0444\u0438\u043d\u0430\u043d\u0441\u043e\u0432\u044b\u0439 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438 \u0441 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u043c \u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448\u0438\u0445 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u0439 \u0438\u043b\u0438 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u0432."
});
expect(meaning.explicit_intent_candidate).toBeNull();
expect(meaning.asked_domain_family).toBe("business_summary");
expect(meaning.asked_action_family).toBe("broad_evaluation");
expect(meaning.explicit_entity_candidates).toEqual([]);
expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation");
expect(meaning.stale_replay_forbidden).toBe(true);
expect(meaning.reason_codes).toContain("compact_cashflow_display_current_turn_signal");
expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal");
expect(meaning.reason_codes).not.toContain("counterparty_turnover_current_turn_signal");
});
it.each([
"\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438 \u0434\u0435\u043d\u044c\u0433\u0430\u043c\u0438 \u0431\u0435\u0437 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u043e\u0432?",
"\u0442\u043e\u043b\u044c\u043a\u043e \u0438\u0442\u043e\u0433: \u043f\u0440\u0438\u0448\u043b\u043e, \u0443\u0448\u043b\u043e, \u043d\u0435\u0442\u0442\u043e",
"\u0434\u0430\u0439 \u043e\u0434\u043d\u043e\u0439 \u0441\u0442\u0440\u043e\u043a\u043e\u0439 \u0434\u0435\u043d\u044c\u0433\u0438 \u0431\u0435\u0437 \u0440\u0430\u0437\u0431\u0438\u0432\u043a\u0438",
"\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 \u0438 \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0431\u0435\u0437 \u0434\u0435\u0442\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438"
])("treats compact cashflow wording '%s' as business-summary display request", (rawUserMessage) => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "customer_revenue_and_payments", confidence: "high" })
});
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage,
effectiveAddressUserMessage:
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u043e\u0431\u043e\u0440\u043e\u0442 \u0438 \u0432\u044b\u0440\u0443\u0447\u043a\u0443 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438 \u0431\u0435\u0437 \u0440\u0430\u0437\u0440\u0435\u0437\u0430 \u043f\u043e \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c."
});
expect(meaning.explicit_intent_candidate).toBeNull();
expect(meaning.asked_domain_family).toBe("business_summary");
expect(meaning.asked_action_family).toBe("broad_evaluation");
expect(meaning.explicit_entity_candidates).toEqual([]);
expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation");
expect(meaning.stale_replay_forbidden).toBe(true);
expect(meaning.reason_codes).toContain("compact_cashflow_display_current_turn_signal");
expect(meaning.reason_codes).not.toContain("counterparty_turnover_current_turn_signal");
});
it("treats organization-level earnings and best-year wording as business overview", () => {
const policy = buildPolicy({
resolveAddressIntent: () => ({ intent: "customer_revenue_and_payments", confidence: "high" })

View File

@ -109,7 +109,8 @@ describe("counterparty analytics reply builders", () => {
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Оборот по СВК за доступное время: 3.500,00");
expect(reply.text).toContain("по 2 подтвержденным входящим операциям");
expect(reply.text).toContain("Это денежный поток от клиента, а не чистая прибыль");
expect(reply.text).toContain("Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.");
expect(reply.text).toContain("Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.");
expect(reply.text).not.toContain("Самый доходный клиент");
expect(reply.text).not.toContain("Топ-");
});

View File

@ -225,4 +225,63 @@ describe("routeHintAdapter", () => {
}
expect(summary.decisions[0]?.route).toBe("store_canonical");
});
it("routes counterparty received-paid-net wording to hybrid instead of canonical fact lookup", () => {
const summary = toRouteHintSummary({
schema_version: "normalized_query_v2_0_2",
user_message_raw: "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
message_in_scope: true,
scope_confidence: "high",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
normalized_fragment_text:
"Определить сумму полученных средств, сумму выплаченных средств и чистый остаток (нетто) для контрагента Группа СВК за период 2020 года.",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: ["Группа СВК"],
account_hints: [],
document_hints: [],
register_hints: [],
time_scope: {
type: "explicit",
value: "2020",
confidence: "high"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
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: "high",
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
}
});
expect(summary.mode).toBe("deterministic_v2");
if (summary.mode !== "deterministic_v2") {
throw new Error("Expected deterministic_v2 summary");
}
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
expect(summary.decisions[0]?.reason).toContain("bidirectional_value_flow");
});
});

Some files were not shown because too many files have changed in this diff Show More