From 09c6d1aa0e848295b99bd12562f36d73bc8d82bf Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 22 May 2026 16:17:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D0=B6=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BD=D0=BE=D0=BC=D0=B5=D0=BD=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../21 - current_status_canon_2026-05-01.md | 69 ++-- ...41 - assistant_context_entry_2026-05-18.md | 178 +++++++++ ...it_milestone_and_next_vector_2026-05-18.md | 175 +++++++++ ...ct_and_semantic_audit_uplift_2026-05-18.md | 360 ++++++++++++++++++ .../11 - architecture_turnaround/README.md | 6 +- docs/TECH/address_route_expectations_v1.json | 6 + ...ry_margin_ranking_agent_loop_20260522.json | 109 ++++++ .../dist/services/addressCapabilityPolicy.js | 10 +- .../services/addressCoverageEvidencePolicy.js | 1 + .../dist/services/addressFilterExtractor.js | 5 + .../dist/services/addressIntentResolver.js | 14 + .../dist/services/addressQueryService.js | 14 + .../dist/services/addressRecipeCatalog.js | 94 +++-- .../address_runtime/decomposeStage.js | 59 ++- .../address_runtime/inventoryReplyBuilders.js | 119 ++++++ ...stantAddressOrchestrationRuntimeAdapter.js | 18 + ...assistantCapabilityBindingResponseGuard.js | 7 + ...ssistantCapabilityRuntimeBindingAdapter.js | 7 + .../assistantMcpDiscoveryResponsePolicy.js | 3 +- .../dist/services/assistantRoutePolicy.js | 4 +- .../assistantRuntimeContractRegistry.js | 12 + .../services/assistantTransitionPolicy.js | 29 ++ .../services/assistantTurnMeaningPolicy.js | 1 + .../src/services/addressCapabilityPolicy.ts | 11 +- .../services/addressCoverageEvidencePolicy.ts | 1 + .../src/services/addressFilterExtractor.ts | 5 + .../src/services/addressIntentResolver.ts | 30 ++ .../src/services/addressQueryService.ts | 14 + .../src/services/addressRecipeCatalog.ts | 14 + .../address_runtime/decomposeStage.ts | 69 +++- .../address_runtime/inventoryReplyBuilders.ts | 182 +++++++++ ...stantAddressOrchestrationRuntimeAdapter.ts | 26 ++ ...assistantCapabilityBindingResponseGuard.ts | 7 + ...ssistantCapabilityRuntimeBindingAdapter.ts | 9 + .../assistantMcpDiscoveryResponsePolicy.ts | 3 +- .../src/services/assistantRoutePolicy.ts | 4 +- .../assistantRuntimeContractRegistry.ts | 12 + .../src/services/assistantTransitionPolicy.ts | 42 +- .../services/assistantTurnMeaningPolicy.ts | 1 + .../backend/src/types/addressQuery.ts | 2 + .../addressIntentResolverRegression.test.ts | 9 + ...fitabilitySelectedObjectRegression.test.ts | 288 ++++++++++++++ ...AddressOrchestrationRuntimeAdapter.test.ts | 64 ++++ ...tantCapabilityBindingResponseGuard.test.ts | 29 ++ ...antCapabilityRuntimeBindingAdapter.test.ts | 28 ++ 45 files changed, 2074 insertions(+), 76 deletions(-) create mode 100644 docs/ARCH/11 - architecture_turnaround/41 - assistant_context_entry_2026-05-18.md create mode 100644 docs/ARCH/11 - architecture_turnaround/42 - project_audit_milestone_and_next_vector_2026-05-18.md create mode 100644 docs/ARCH/11 - architecture_turnaround/43 - business_answer_contract_and_semantic_audit_uplift_2026-05-18.md create mode 100644 docs/orchestration/inventory_margin_ranking_agent_loop_20260522.json diff --git a/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md b/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md index 8a9750b..55910dc 100644 --- a/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md +++ b/docs/ARCH/11 - architecture_turnaround/21 - current_status_canon_2026-05-01.md @@ -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. diff --git a/docs/ARCH/11 - architecture_turnaround/41 - assistant_context_entry_2026-05-18.md b/docs/ARCH/11 - architecture_turnaround/41 - assistant_context_entry_2026-05-18.md new file mode 100644 index 0000000..241bd42 --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/41 - assistant_context_entry_2026-05-18.md @@ -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, а не через свободную импровизацию. diff --git a/docs/ARCH/11 - architecture_turnaround/42 - project_audit_milestone_and_next_vector_2026-05-18.md b/docs/ARCH/11 - architecture_turnaround/42 - project_audit_milestone_and_next_vector_2026-05-18.md new file mode 100644 index 0000000..31a3f4f --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/42 - project_audit_milestone_and_next_vector_2026-05-18.md @@ -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. diff --git a/docs/ARCH/11 - architecture_turnaround/43 - business_answer_contract_and_semantic_audit_uplift_2026-05-18.md b/docs/ARCH/11 - architecture_turnaround/43 - business_answer_contract_and_semantic_audit_uplift_2026-05-18.md new file mode 100644 index 0000000..79f5dc0 --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/43 - business_answer_contract_and_semantic_audit_uplift_2026-05-18.md @@ -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. diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 65da3e0..9941afe 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -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 diff --git a/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index f0ec680..17f0491 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -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"], diff --git a/docs/orchestration/inventory_margin_ranking_agent_loop_20260522.json b/docs/orchestration/inventory_margin_ranking_agent_loop_20260522.json new file mode 100644 index 0000000..03226b3 --- /dev/null +++ b/docs/orchestration/inventory_margin_ranking_agent_loop_20260522.json @@ -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" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js index c580c58..3c087a3 100644 --- a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js +++ b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js @@ -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") { diff --git a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js index 8fe63f7..ffb0b23 100644 --- a/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js +++ b/llm_normalizer/backend/dist/services/addressCoverageEvidencePolicy.js @@ -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" || diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index dbd6145..757998b 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -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"]; } diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 525dd91..a1c8c26 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -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) { diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 97c7e03..7962d87 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -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, [ "По текущим условиям в доступном срезе данных совпадений не нашлось.", diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index fd4d839..6a4d038 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -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, diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 22cc373..c3d88a5 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -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 || diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 22521bf..afa1a40 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -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)); diff --git a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js index 45ff59e..fad6768 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js @@ -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")); diff --git a/llm_normalizer/backend/dist/services/assistantCapabilityBindingResponseGuard.js b/llm_normalizer/backend/dist/services/assistantCapabilityBindingResponseGuard.js index af090b8..a278d1b 100644 --- a/llm_normalizer/backend/dist/services/assistantCapabilityBindingResponseGuard.js +++ b/llm_normalizer/backend/dist/services/assistantCapabilityBindingResponseGuard.js @@ -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)}.`, diff --git a/llm_normalizer/backend/dist/services/assistantCapabilityRuntimeBindingAdapter.js b/llm_normalizer/backend/dist/services/assistantCapabilityRuntimeBindingAdapter.js index 300c7d2..aa09347 100644 --- a/llm_normalizer/backend/dist/services/assistantCapabilityRuntimeBindingAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantCapabilityRuntimeBindingAdapter.js @@ -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") || diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 2300503..a476961 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -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)) { diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 4c6f3e8..2e5d7be 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -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"; diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js index b98ab8b..5fe11d2 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js @@ -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"], diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index af17620..db3ec5b 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -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) diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index 01c2fc3..08c0f8e 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -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", diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts index aa940b1..6bbc4a0 100644 --- a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -31,6 +31,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", @@ -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" diff --git a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts index ceb58ed..dfb4ec0 100644 --- a/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts +++ b/llm_normalizer/backend/src/services/addressCoverageEvidencePolicy.ts @@ -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" || diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 22c1b55..3e3095a 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -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 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, [ diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 30a1aed..8886b0d 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -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" diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index f763ef2..3476c76 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -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 && diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 1b49c13..e1ff46a 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -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(); + 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)); diff --git a/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts index b38cdc2..40e3cca 100644 --- a/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts @@ -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)) && diff --git a/llm_normalizer/backend/src/services/assistantCapabilityBindingResponseGuard.ts b/llm_normalizer/backend/src/services/assistantCapabilityBindingResponseGuard.ts index 41f2df2..cb0c85c 100644 --- a/llm_normalizer/backend/src/services/assistantCapabilityBindingResponseGuard.ts +++ b/llm_normalizer/backend/src/services/assistantCapabilityBindingResponseGuard.ts @@ -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)}.`, diff --git a/llm_normalizer/backend/src/services/assistantCapabilityRuntimeBindingAdapter.ts b/llm_normalizer/backend/src/services/assistantCapabilityRuntimeBindingAdapter.ts index d206091..68b1b6c 100644 --- a/llm_normalizer/backend/src/services/assistantCapabilityRuntimeBindingAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantCapabilityRuntimeBindingAdapter.ts @@ -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") || diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 839ccad..b8d0666 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -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 ?? "") ); } diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 6911794..e568ccb 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -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"; diff --git a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts index 7eb7922..d9ef9fa 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts @@ -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"], diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index d6241ff..46e9e4b 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -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) diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index ce364bb..8375b58 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -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", diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index cb3997e..c109dea 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -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" diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts index d80e779..58b9913 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -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"); diff --git a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts index ef9e27a..8131bd3 100644 --- a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts @@ -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); + }); }); diff --git a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts index ae7846e..973576a 100644 --- a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts @@ -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: { diff --git a/llm_normalizer/backend/tests/assistantCapabilityBindingResponseGuard.test.ts b/llm_normalizer/backend/tests/assistantCapabilityBindingResponseGuard.test.ts index 39fb79e..73fca5c 100644 --- a/llm_normalizer/backend/tests/assistantCapabilityBindingResponseGuard.test.ts +++ b/llm_normalizer/backend/tests/assistantCapabilityBindingResponseGuard.test.ts @@ -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", diff --git a/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts b/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts index 951e8f9..e572fc3 100644 --- a/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantCapabilityRuntimeBindingAdapter.test.ts @@ -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: {