Стабилизировать маржинальность номенклатуры

This commit is contained in:
dctouch 2026-05-22 16:17:07 +03:00
parent f5d86d4bc1
commit 09c6d1aa0e
45 changed files with 2074 additions and 76 deletions

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,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

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

@ -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,6 +81,48 @@ function inventoryProfitabilityPeriodLabel(options, deps) {
const asOfDate = typeof options.asOfDate === "string" && options.asOfDate.trim().length > 0 ? options.asOfDate : null;
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
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);
@ -401,6 +443,83 @@ 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 lines = [`За период ${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 (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

@ -61,6 +61,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 +75,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 +127,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 +146,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"));

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

@ -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)) {

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";

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))
@ -545,6 +565,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) &&
@ -573,6 +594,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
@ -582,6 +604,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
@ -622,6 +645,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -645,6 +669,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -757,6 +782,7 @@ function createAssistantTransitionPolicy(deps) {
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!inventoryMarginRankingFollowup &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
@ -816,6 +842,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
@ -827,6 +854,7 @@ function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
@ -849,6 +877,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",

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

@ -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,73 @@ function inventoryProfitabilityPeriodLabel(options: InventoryComposeOptions, dep
return asOfDate ? `до ${deps.formatDateRu(asOfDate)}` : "по доступной выборке";
}
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[],
@ -548,6 +615,121 @@ 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 lines: string[] = [`За период ${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 (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

@ -157,6 +157,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 +176,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 +253,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 +282,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)) &&

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

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

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";

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))
@ -760,6 +785,11 @@ export function createAssistantTransitionPolicy(deps) {
sourceIntentHint,
hasNavigationInventoryItemFocusHint
);
const inventoryMarginRankingFollowup = hasInventoryMarginRankingFollowupSignal(
userMessage,
alternateMessage,
sourceIntentHint
);
let inventoryShortFollowupPrimary =
(deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage);
@ -796,6 +826,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
@ -805,6 +836,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
@ -862,6 +894,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -886,6 +919,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
inventoryMarginRankingFollowup ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -1054,8 +1088,9 @@ export function createAssistantTransitionPolicy(deps) {
!inventoryShortFollowupPrimary &&
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!foreignAccountingPivotOverInventory &&
!businessOverviewBoundaryFollowupAlternate &&
!inventoryMarginRankingFollowup &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
(!deps.toNonEmptyString(alternateMessage)
@ -1118,6 +1153,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
@ -1129,6 +1165,7 @@ export function createAssistantTransitionPolicy(deps) {
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
inventoryMarginRankingFollowup ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
@ -1151,6 +1188,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",

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 \u0447\u0438\u0441\u0442\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438");
expect(reply).not.toContain("\u041e\u0421");
expect(reply).not.toContain("\u0430\u043c\u043e\u0440\u0442\u0438\u0437");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -499,6 +499,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: {