From edab736a6dfd0c6f9c39863e8a015f0d36ee1aeb Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 4 May 2026 08:20:46 +0300 Subject: [PATCH] =?UTF-8?q?Open-World:=20=D1=80=D0=B0=D1=81=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D1=8C=20=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81?= =?UTF-8?q?-=D0=BE=D0=B1=D0=B7=D0=BE=D1=80=20=D1=84=D0=B0=D0=BA=D1=82-?= =?UTF-8?q?=D1=81=D0=B5=D0=BC=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../21 - current_status_canon_2026-05-01.md | 12 +- ...rld_bounded_autonomy_breadth_2026-05-01.md | 157 +- .../11 - architecture_turnaround/README.md | 25 +- ..._business_overview_multi_probe_bridge.json | 106 ++ ..._phase85_business_overview_tax_family.json | 78 + ...ase86_business_overview_debt_position.json | 79 + ..._business_overview_inventory_position.json | 76 + ...ness_overview_open_settlement_quality.json | 80 + .../assistantMcpDiscoveryAnswerAdapter.js | 190 ++- .../assistantMcpDiscoveryPilotExecutor.js | 1054 +++++++++++++- .../assistantMcpDiscoveryTurnInputAdapter.js | 197 ++- .../services/assistantTurnMeaningPolicy.js | 7 +- .../assistantMcpDiscoveryAnswerAdapter.ts | 191 ++- .../assistantMcpDiscoveryPilotExecutor.ts | 1289 ++++++++++++++++- .../assistantMcpDiscoveryTurnInputAdapter.ts | 117 +- .../services/assistantTurnMeaningPolicy.ts | 9 +- ...assistantMcpDiscoveryAnswerAdapter.test.ts | 277 ++++ ...assistantMcpDiscoveryPilotExecutor.test.ts | 344 ++++- .../assistantMcpDiscoveryPlanner.test.ts | 2 +- ...ssistantMcpDiscoveryResponsePolicy.test.ts | 63 + ...stantMcpDiscoveryRuntimeEntryPoint.test.ts | 66 + ...istantMcpDiscoveryTurnInputAdapter.test.ts | 118 +- .../tests/assistantTurnMeaningPolicy.test.ts | 19 + 23 files changed, 4408 insertions(+), 148 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase84_business_overview_multi_probe_bridge.json create mode 100644 docs/orchestration/address_truth_harness_phase85_business_overview_tax_family.json create mode 100644 docs/orchestration/address_truth_harness_phase86_business_overview_debt_position.json create mode 100644 docs/orchestration/address_truth_harness_phase87_business_overview_inventory_position.json create mode 100644 docs/orchestration/address_truth_harness_phase88_business_overview_open_settlement_quality.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 49c30db..97e9bae 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 @@ -15,8 +15,14 @@ If another document says `78%`, `87%`, `92%`, or `85%` for a module that is now - `Planner Autonomy Consolidation`: `100%` for the declared phase83 planner-brain slice, including catalog alignment, live-readiness gating, checked-source sanitation, and accepted mixed replay. - Active next module: broader `Open-World Bounded Autonomy Breadth` over unfamiliar 1C asks, while keeping Post-F and phase83 as regression gates. - Completed active slice: `Business Overview Evidence Fusion`, tracked in `22 - open_world_bounded_autonomy_breadth_2026-05-01.md`. -- Current active slice: `Business Overview Catalog Route Fabric`: the route is reviewed in catalog/data-need/planner contracts, while fresh multi-probe runtime execution remains a pending bridge. -- Active module progress: `~18% (Open-World Bounded Autonomy Breadth)`. +- Completed active slice: `Business Overview Catalog Route Fabric`: the route is reviewed in catalog/data-need/planner contracts and exposes the stable `business_overview` route scope. +- Completed active slice: `Business Overview Fresh Multi-Probe Runtime Bridge`: the reviewed route now executes incoming money flow, outgoing supplier payout, activity-window, net-spread, top-customer, and analyst-safe answer drafting, and has passed live semantic replay against the real assistant runtime. +- Completed active slice: `Business Overview VAT/Tax Fact-Family Bridge`: explicit-period business overview can include confirmed VAT/tax position, while all-time follow-ups and negated VAT periods do not reuse stale tax scope. +- Completed active slice: `Business Overview Debt-Position Fact-Family Bridge`: explicit-period business overview can include confirmed receivables/payables as-of-date debt position, while all-time follow-ups do not reuse stale debt snapshots and debt quality/aging remains unclaimed. +- Completed active slice: `Business Overview Inventory-Position Fact-Family Bridge`: explicit-date business overview can include confirmed stock-on-hand inventory position, while all-time follow-ups do not reuse stale inventory snapshots and inventory liquidity/turnover remains unclaimed. +- Completed active slice: `Business Overview Open-Settlement Quality Bridge`: explicit-period business overview can check open-contract settlement concentration on 60/62/76, while due-date aging/overdue debt remains unclaimed until a reviewed due-date route exists. +- Next active slice: continue `Business Overview Fact-Family Expansion` into profit/margin and due-date debt aging where reviewed routes exist. +- Active module progress: `~60% (Open-World Bounded Autonomy Breadth)`. ## Reporting Rule @@ -53,7 +59,7 @@ The project is not yet a universal arbitrary-1C agent. Remaining work belongs to the next breadth module: -- implement the fresh multi-probe `business_overview` runtime bridge behind the reviewed route-fabric contract; +- extend `business_overview` beyond money-flow/activity, explicit-period VAT/tax, as-of-date debt position, open-settlement concentration, and as-of-date inventory position into separately proven profit/margin, due-date debt aging/overdue, and real inventory-liquidity evidence families; - broader dynamic schema traversal for unfamiliar 1C asks; - more primitive descriptors where live evidence proves a real gap; - more replay-backed domain packs that start from user business meaning, not from route convenience; diff --git a/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md b/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md index ff086f0..63448fa 100644 --- a/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md +++ b/docs/ARCH/11 - architecture_turnaround/22 - open_world_bounded_autonomy_breadth_2026-05-01.md @@ -82,18 +82,123 @@ Implemented now: - the data-need graph recognizes broad company analysis as a bounded business-overview evidence need; - fresh business-overview probes require an organization scope instead of silently reusing stale context; - planner output can select `business_overview` as the catalog top match with structured alignment telemetry; -- the pilot executor exposes `business_overview_route_template_v1` as an explicit scope, but returns an unsupported runtime boundary until the fresh multi-probe bridge is implemented. +- the pilot executor exposes `business_overview_route_template_v1` as the stable runtime scope for the next bridge. -This is deliberately not a fake runtime success. +This slice was deliberately only route fabric: it made the reviewed route visible without pretending fresh runtime evidence existed yet. -The assistant now has the route-fabric contract for the next slice, while the live business overview still uses the safe evidence-fusion bridge from Slice 1. +The assistant now has the route-fabric contract used by Slice 3. -### Still Pending Runtime Slice +## Slice 3 - Business Overview Fresh Multi-Probe Runtime Bridge -Promote this bridge into a real planner route: +This slice makes the reviewed `business_overview` route execute as a bounded multi-probe MCP discovery bridge. -- run bounded fresh probes for year turnover, top customers, incoming/outgoing/net flow, debt, VAT, and inventory context where available; -- return a layered analyst answer with exact evidence, bounded inference, unknowns, and recommended next probes. +Implemented now: + +- broad company-analysis turn meaning routes into `business overview evidence with bounded analyst interpretation` instead of being kept only in deterministic living chat; +- the bridge runs fresh scoped probes for incoming customer money flow, outgoing supplier payouts, and activity-window evidence; +- the pilot derives checked incoming total, checked outgoing total, net cash-flow spread, net direction, top confirmed customer, and activity window; +- the answer adapter turns those derived facts into a layered analyst-safe draft: confirmed facts, bounded interpretation, unknowns, and next probes; +- response policy can replace the old deterministic broad summary only when the discovery candidate has grounded text, while clarification candidates still preserve the safe deterministic answer; +- user-facing answers must not expose `business_overview_route_template_v1`, MCP primitive names, raw planner/debug labels, or profit/margin claims. + +Live semantic replay is now accepted for this slice: + +- `address_truth_harness_phase84_business_overview_multi_probe_bridge_live_20260503_runtime_bridge2` passed `3/3`; +- step 1 proves explicit company business overview for `ООО Альтернатива Плюс`; +- step 2 proves an exact `Группа СВК` 2020 net-flow follow-up after the company overview without stale organization-scope contamination; +- step 3 proves returning to `ООО Альтернатива Плюс` business overview after a counterparty pivot without treating net cash-flow as profit. + +## Slice 4 - Business Overview VAT/Tax Fact-Family Bridge + +This slice adds the first separately checked fact family beyond money-flow/activity. + +Implemented now: + +- explicit-period business overview can select the reviewed VAT/tax recipe and execute the tax probe beside the money-flow/activity probes; +- the pilot derives sales VAT, purchase/deduction VAT, net VAT direction, and the checked tax period only from confirmed VAT rows; +- the answer adapter can surface VAT/tax position as a confirmed line without treating net cash-flow as profit or margin; +- broad all-time business overview does not silently reuse a prior or negated VAT period; +- wording such as `do not carry VAT for 2020` / `не тащи НДС за 2020` is treated as a temporal exclusion, not as the active period for the current turn; +- organization extraction strips trailing all-time/business-overview clauses so the company scope remains the company name, not the whole user sentence. + +Live semantic replay is accepted for this slice: + +- `address_truth_harness_phase85_business_overview_tax_family_live_20260504_taxfamily2` passed `2/2`; +- step 1 proves explicit 2020 business overview may include a confirmed VAT/tax position; +- step 2 proves all-time follow-up over the same company does not reuse the 2020 VAT/tax position from the prior turn or from the negated wording; +- the accepted debug path shows `explicit_date_scope=null`, `tax_position=null`, and `pilot_business_overview_tax_probe_skipped_without_explicit_period` for the all-time follow-up. + +## Slice 5 - Business Overview Debt-Position Fact-Family Bridge + +This slice adds a second separately checked fact family, but deliberately stops short of debt quality. + +Implemented now: + +- explicit-period business overview can derive an as-of-date from the user-visible period and execute reviewed receivables/payables snapshot recipes beside money-flow/activity and optional VAT/tax probes; +- the pilot derives receivables, payables, net debt-position amount, net direction, and top debt-side counterparties only from confirmed 1C balance rows; +- the answer adapter can surface the debt-position snapshot as a confirmed line without treating it as overdue debt, debt aging, credit quality, profit, or margin; +- all-time business overview does not reuse the prior as-of-date debt snapshot and instead keeps debt position as an unknown fact family until the user gives a new explicit date; +- raw organization extraction now strips trailing explicit period clauses such as `за 2020 год` / `на 2020-12-31`, so the company scope remains the company name and the period remains a separate temporal axis. + +Live semantic replay is accepted for this slice: + +- `address_truth_harness_phase86_business_overview_debt_position_live_20260504_debt2` passed `2/2`; +- step 1 proves explicit 2020 business overview may include a confirmed receivables/payables debt-position snapshot on `2020-12-31`; +- step 2 proves an all-time follow-up over the same company does not reuse the `2020-12-31` debt snapshot as current or all-time debt position; +- the accepted debug path shows `organization_scope=ООО Альтернатива Плюс`, `explicit_date_scope=2020`, `debt_position.as_of_date=2020-12-31` for the explicit-period step, and `explicit_date_scope=null`, `debt_position=null`, `pilot_business_overview_debt_probe_skipped_without_explicit_as_of_date` for the all-time follow-up. + +## Slice 6 - Business Overview Inventory-Position Fact-Family Bridge + +This slice adds a third separately checked fact family, but deliberately stops short of warehouse liquidity or turnover. + +Implemented now: + +- explicit-date business overview can derive an as-of date and execute reviewed inventory on-hand and optional purchase-date aging probes beside the existing money-flow/activity, VAT/tax, and debt-position probes; +- the pilot derives inventory on-hand rows, rows with amount, rows with quantity, total stock amount, total quantity, top stock items, and optional purchase-date aging signal only from confirmed 1C inventory rows; +- the answer adapter can surface the inventory-position snapshot as a confirmed line without treating it as turnover, obsolescence, liquidation value, full inventory health, profit, or margin; +- if the current business-overview slice has no incoming/outgoing money rows, the answer no longer emits a fake `net 0` cash-flow interpretation; +- business-overview headlines now list only the fact families that were actually confirmed in the current turn, so a stock/debt/tax snapshot does not claim money-flow or activity when those rows were absent; +- all-time business overview does not reuse a prior as-of-date inventory snapshot and instead keeps stock/inventory position as an unknown fact family until the user gives a new explicit date. + +Live semantic replay is accepted for this slice: + +- `address_truth_harness_phase87_business_overview_inventory_position_live_20260504_inventory2` passed `2/2`; +- step 1 proves explicit-date business overview may include a confirmed warehouse stock snapshot on `2026-04-16`; +- step 2 proves an all-time follow-up over the same company does not reuse the `2026-04-16` stock snapshot as current or all-time warehouse position; +- the accepted user-facing answer confirms stock amount `716 418,33` rub. over `11` rows and keeps warehouse liquidity/turnover as unconfirmed. + +## Slice 7 - Business Overview Open-Settlement Quality Bridge + +This slice widens the debt family from a simple receivables/payables position into a bounded quality signal, but deliberately stops short of due-date aging or confirmed overdue debt. + +Implemented now: + +- explicit-period business overview can execute the reviewed `open_contracts_confirmed_as_of_date` balance recipe beside money-flow/activity, VAT/tax, debt-position, and inventory probes; +- the pilot derives gross open settlement amount, unique open-contract count, unique counterparty count, top open contracts, top counterparties, and concentration percentages only from confirmed 1C balance rows on 60/62/76; +- the answer adapter can surface this as `качество открытых расчетов` without treating concentration as contractual due-date aging, confirmed overdue debt, credit risk, profit, or margin; +- all-time business overview keeps open-settlement quality unknown unless the current turn has a fresh explicit as-of date; +- the remaining hard boundary is still due-date/overdue aging: open contracts prove concentration of open balances, not payment-term delinquency. + +Live semantic replay is accepted for this slice: + +- `npm.cmd test -- assistantMcpDiscoveryPilotExecutor.test.ts`: passed `31/31`; +- `npm.cmd test -- assistantMcpDiscoveryAnswerAdapter.test.ts`: passed `34/34` with `1` skipped; +- `npm.cmd test -- assistantMcp`: passed `305/305` with `9` skipped; +- `npm.cmd run build`: passed; +- `address_truth_harness_phase88_business_overview_open_settlement_quality_live_20260504_openquality4` passed `2/2`; +- step 1 proves explicit 2020 business overview may include confirmed open-settlement concentration: `35 472 380,36` rub. gross open contract balances, `8` contracts, `11` counterparties, and top contract share `54.2%`; +- step 2 proves an all-time follow-up does not reuse the `2020-12-31` open-contract/debt snapshot as current overdue debt or all-time debt quality; +- the accepted user-facing answer keeps due-date aging and overdue debt as unconfirmed because open contracts prove concentration of balances, not payment-term delinquency. + +### Still Pending Breadth Slices + +Grow this bridge beyond the first confirmed signal bundle: + +- add separate evidence families for profit/margin and due-date debt aging/overdue quality where reviewed routes exist; +- extend inventory evidence from as-of-date stock position into real turnover/liquidity only when reviewed sales velocity, aging, or obsolescence evidence exists; +- extend debt evidence from as-of-date position/open-settlement concentration into overdue aging only when reviewed due-date or aging evidence exists; +- extend VAT/tax beyond explicit-period tax position only when the requested tax fact is provable and the period is explicit; +- keep Post-F stale-scope and phase83 catalog-alignment canaries green while widening the route. ## Acceptance Signals @@ -112,11 +217,43 @@ Initial local validation: - `npm.cmd test -- assistantTurnMeaningPolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts`: passed `20/20`. - `npm.cmd test -- assistantTurnMeaningPolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts assistantRoutePolicy.test.ts assistantMcpDiscoveryResponsePolicy.test.ts`: passed `56/56`. - `npm.cmd run build`: passed. -- graphify rebuild: `5977 nodes`, `12983 edges`, `137 communities`. +- graphify rebuild at Slice 2 boundary: `5977 nodes`, `12983 edges`, `137 communities`. Business-overview route-fabric validation: - `npm.cmd test -- assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryDataNeedGraph.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryPilotExecutor.test.ts`: passed `102/102`. -- fresh multi-probe runtime execution remains intentionally pending behind `business_overview_route_template_v1`. -Graphify must be rebuilt after this code/doc slice before commit. +Business-overview fresh runtime bridge validation: + +- `npm.cmd test -- assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryTurnInputAdapter.test.ts assistantMcpDiscoveryResponsePolicy.test.ts assistantMcpDiscoveryPlanner.test.ts`: passed `211/211` with `9` skipped. +- `npm.cmd test -- assistantMcp`: passed `296/296` with `9` skipped. +- `npm.cmd run build`: passed. +- live replay `address_truth_harness_phase84_business_overview_multi_probe_bridge_live_20260503_runtime_bridge2`: accepted `3/3` with `catalog_alignment_ok=true`, `human_answer_quality_ok=true`, and no internal route/debug terms in the user-facing answer. + +Business-overview VAT/tax fact-family validation: + +- `npm.cmd test -- assistantMcpDiscoveryTurnInputAdapter.test.ts`: passed `76/76` with `6` skipped. +- `npm.cmd test -- assistantMcp`: passed `300/300` with `9` skipped. +- `npm.cmd run build`: passed. +- live replay `address_truth_harness_phase85_business_overview_tax_family_live_20260504_taxfamily2`: accepted `2/2` and proved that a negated 2020 VAT period is not reused as an all-time tax position. + +Business-overview debt-position fact-family validation: + +- `npm.cmd test -- assistantMcpDiscoveryPilotExecutor.test.ts`: passed `30/30`. +- `npm.cmd test -- assistantMcpDiscoveryAnswerAdapter.test.ts`: passed `33/33` with `1` skipped. +- `npm.cmd test -- assistantMcpDiscoveryTurnInputAdapter.test.ts`: passed `77/77` with `6` skipped. +- `npm.cmd test -- assistantMcp`: passed `303/303` with `9` skipped. +- `npm.cmd run build`: passed. +- live replay `address_truth_harness_phase86_business_overview_debt_position_live_20260504_debt2`: accepted `2/2` and proved that explicit debt-position snapshots do not leak into all-time follow-ups. + +Business-overview inventory-position fact-family validation: + +- `npm.cmd test -- assistantMcpDiscoveryPilotExecutor.test.ts`: passed `31/31`. +- `npm.cmd test -- assistantMcpDiscoveryAnswerAdapter.test.ts`: passed `34/34` with `1` skipped. +- `npm.cmd test -- assistantMcp`: passed `305/305` with `9` skipped. +- `npm.cmd run build`: passed. +- live replay `address_truth_harness_phase87_business_overview_inventory_position_live_20260504_inventory2`: accepted `2/2` and proved that explicit inventory-position snapshots do not leak into all-time follow-ups. + +Graphify rebuild after Slice 6 code/doc sync: `6001 nodes`, `13058 edges`, `140 communities`. + +Graphify rebuild after Slice 7 code/doc sync: `6008 nodes`, `13078 edges`, `138 communities`. diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 151bcf1..3fcb4b6 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -41,7 +41,7 @@ This package answers the next question: 21. [21 - current_status_canon_2026-05-01.md](./21%20-%20current_status_canon_2026-05-01.md) 22. [22 - open_world_bounded_autonomy_breadth_2026-05-01.md](./22%20-%20open_world_bounded_autonomy_breadth_2026-05-01.md) -## Current Status Snapshot (2026-05-01) +## Current Status Snapshot (2026-05-04) This package is no longer planning-only. @@ -51,7 +51,12 @@ Status canon for planning: - Planner Autonomy Consolidation is closed at `100%` for the declared phase83 planner-brain slice. - The active next module is now `Open-World Bounded Autonomy Breadth` over unfamiliar 1C asks, with Post-F and phase83 retained as semantic canaries. - The first active slice is `Business Overview Evidence Fusion`: broad company-analysis wording now produces a richer evidence-grounded business overview from confirmed MCP/session facts instead of a thin generic summary. -- The current follow-up slice is `Business Overview Catalog Route Fabric`: `business_overview` is now a reviewed catalog/data-need/planner chain, while fresh multi-probe execution remains honestly bounded behind an unsupported runtime scope until the next bridge is implemented. +- The current completed slice is `Business Overview Fresh Multi-Probe Runtime Bridge`: `business_overview` is now a reviewed catalog/data-need/planner chain and a live-replay accepted runtime bridge over incoming money flow, outgoing supplier payouts, activity-window evidence, net-spread, top customer, and analyst-safe answer drafting. +- The current completed breadth slice is `Business Overview VAT/Tax Fact-Family Bridge`: explicit-period business overview can include confirmed VAT/tax position, while all-time follow-ups and negated VAT period wording do not reuse stale tax scope. +- The current completed breadth slice is `Business Overview Debt-Position Fact-Family Bridge`: explicit-period business overview can include confirmed receivables/payables as-of-date debt position, while all-time follow-ups do not reuse stale debt snapshots and debt quality/aging remains unclaimed. +- The current completed breadth slice is `Business Overview Inventory-Position Fact-Family Bridge`: explicit-date business overview can include confirmed stock-on-hand inventory position, while all-time follow-ups do not reuse stale inventory snapshots and inventory liquidity/turnover remains unclaimed. +- The current completed breadth slice is `Business Overview Open-Settlement Quality Bridge`: explicit-period business overview can check open-contract settlement concentration, while due-date aging and confirmed overdue debt remain outside the answer until a reviewed due-date route exists. +- The next active breadth slice continues `Business Overview Fact-Family Expansion` into profit/margin and due-date debt aging, then broader unfamiliar 1C route breadth without relaxing truth boundaries. - The short source of truth for status wording is [21 - current_status_canon_2026-05-01.md](./21%20-%20current_status_canon_2026-05-01.md). 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: @@ -86,7 +91,7 @@ It now documents a turnaround that is already operational in code, already mater - explicit document/movement data-need now scores over ambiguous carried metadata surfaces without forcing neutral follow-ups into a lane; - lifecycle now behaves as a bounded activity-window inference chain with an explicit legal-fact boundary instead of an unqualified age answer; - current-turn value-flow aggregate questions can override narrower supported exact routes when the user asks for totals/net/payment amounts; - - broad business evaluation remains in the deterministic living-chat bridge instead of being displaced by generic metadata discovery; + - broad business evaluation remained guarded during phase83 and is now carried forward in the next breadth module as a reviewed `business_overview` discovery route instead of being displaced by generic metadata discovery; - inventory stock snapshot, supplier overlap, purchase provenance, and sale trace are now reviewed catalog chain templates; generic free-form inventory execution remains forbidden, and evidence must pass through reviewed exact recipe bridges; - runtime bridge and answer adapter now keep unsupported inventory route templates behind an explicit user-facing boundary instead of letting template planning look like confirmed stock/supplier/purchase/sale evidence; - inventory catalog templates now bridge through existing exact inventory recipes (`41.01` scoped stock, supplier overlap, purchase provenance, and sale trace) inside the bounded MCP discovery pilot, while missing selected-item anchors still clarify instead of guessing; @@ -115,12 +120,12 @@ Current honest status: - exit-from-danger-zone readiness: `~97%` - pre-multidomain readiness: `~90%` - bounded-autonomy foundation readiness: `~89%` -- open-world bounded-autonomy readiness: `~86%` -- active Open-World Bounded Autonomy Breadth progress: `~18%`, with business-overview evidence fusion and the reviewed `business_overview` catalog/data-need/planner route-fabric slice locally tested; fresh multi-probe runtime execution is still pending +- open-world bounded-autonomy readiness: `~87%` +- active Open-World Bounded Autonomy Breadth progress: `~60%`, with business-overview evidence fusion, the reviewed `business_overview` catalog/data-need/planner route-fabric slice, the fresh multi-probe runtime bridge, the explicit-period VAT/tax fact-family bridge, the explicit-period debt-position bridge, the explicit-date inventory-position bridge, and the open-settlement quality bridge accepted by live semantic replay; profit/margin, due-date debt aging/overdue, and real inventory-liquidity expansion are still pending - Post-F semantic integrity module progress: `~99%` operationally closed, with remaining risk now treated as next-slice discovery rather than an open blocker inside the closed slice - active inventory-stock breadth slice progress: `100%` for the declared scenario pack, not for arbitrary inventory questions - Planner Autonomy Consolidation progress: `100%` for the declared module, with catalog-fabric, value-flow arbitration, lifecycle bounded inference, broad-evaluation bridge, inventory catalog templates, inventory runtime-boundary honesty, exact inventory recipe bridging, unambiguous metadata-surface lane inference, catalog chain-template scoring, structured chain-match contract exposure, runtime/debug propagation, subject-aware bidirectional comparison arbitration, structured catalog-alignment verdicts, representative alignment regression guard, catalog-alignment reason-code telemetry, explicit `alignment_status` propagation, truth-harness/acceptance-matrix surfacing, soft divergence warning, `catalog_alignment_ok` acceptance invariant, step-level expected catalog-alignment assertions, phase66 and phase32 spec alignment expectations, AGENT source-catalog surfacing, generated phase83 mixed planner-brain replay spec, checked-source user-facing error sanitation, surface-grounded catalog promotion, and guarded live phase83 acceptance validated. Broader unfamiliar 1C asks are now next-module breadth work rather than an open blocker inside this declared slice -- graph snapshot after latest rebuild: `5977 nodes`, `12983 edges`, `137 communities` +- graph snapshot after latest rebuild: `6008 nodes`, `13078 edges`, `138 communities` - current regression-gate breakpoint: - the validated hot paths are no longer structurally broken; - flagship continuity collapse is no longer the primary risk; @@ -160,7 +165,13 @@ Latest live proof now includes: - lifecycle/value-flow Planner Autonomy response gate accepted: `address_truth_harness_phase19_mcp_discovery_response_gate_planner_lifecycle_rerun4` accepted `8/8`, proving bounded lifecycle inference, current-turn value-flow aggregate arbitration, and sanitized evidence wording - broad-evaluation bridge continuity accepted: `address_truth_harness_phase21_net_followup_after_broad_eval_planner_lifecycle_rerun2` accepted `3/3` and `address_truth_harness_phase22_broad_business_evaluation_bridge_planner_lifecycle_rerun2` accepted `3/3` - latest local Planner Autonomy slice accepted: full MCP-discovery suite passed `268/268` with `9` skipped; broad MCP/living-chat/route/meaning slice passed `305/305` with `9` skipped; build passed -- business-overview route-fabric slice accepted locally: catalog/data-need/planner/pilot boundary slice passed `102/102`; the pilot executor exposes `business_overview_route_template_v1` as an explicit unsupported scope until the fresh multi-probe bridge exists +- business-overview route-fabric slice accepted locally: catalog/data-need/planner/pilot boundary slice passed `102/102`, proving the reviewed `business_overview` chain and stable route scope +- business-overview fresh multi-probe runtime bridge accepted locally: targeted runtime-entry/pilot/answer/turn-input/response-policy/planner slice passed `211/211` with `9` skipped; full MCP-discovery suite passed `296/296` with `9` skipped; build passed +- business-overview fresh multi-probe runtime bridge accepted live: `address_truth_harness_phase84_business_overview_multi_probe_bridge_live_20260503_runtime_bridge2` accepted `3/3`, proving explicit company overview, exact counterparty net-flow after the company overview, and explicit company overview after a counterparty pivot with `catalog_alignment_ok=true`, `human_answer_quality_ok=true`, and no internal route/debug leakage in the user-facing answer +- business-overview VAT/tax fact-family bridge accepted live: `address_truth_harness_phase85_business_overview_tax_family_live_20260504_taxfamily2` accepted `2/2`, proving explicit-period VAT/tax position in the company overview and all-time follow-up protection against stale or negated VAT-period reuse +- business-overview debt-position fact-family bridge accepted live: `address_truth_harness_phase86_business_overview_debt_position_live_20260504_debt2` accepted `2/2`, proving explicit-period receivables/payables as-of-date debt position and all-time follow-up protection against stale debt snapshot reuse +- business-overview inventory-position fact-family bridge accepted live: `address_truth_harness_phase87_business_overview_inventory_position_live_20260504_inventory2` accepted `2/2`, proving explicit-date stock-on-hand position and all-time follow-up protection against stale inventory snapshot reuse +- business-overview open-settlement quality bridge accepted live: `address_truth_harness_phase88_business_overview_open_settlement_quality_live_20260504_openquality4` accepted `2/2`, proving explicit-period open-contract concentration and all-time follow-up protection against stale open-contract/debt-quality reuse - inventory template lift accepted locally: catalog/data-need/planner/turn-input slice passed `139/139` with `6` skipped; full MCP-discovery slice passed `276/276` with `9` skipped; build passed; graphify stayed at `5912 nodes`, `12833 edges`, `138 communities` - inventory runtime-boundary hardening accepted locally: runtime-bridge/answer-adapter/pilot-executor slice passed `68/68` with `1` skipped; full MCP-discovery slice passed `277/277` with `9` skipped; build passed; graphify rebuilt to `5913 nodes`, `12837 edges`, `138 communities` - inventory exact-runtime bridge accepted locally: runtime-bridge/answer-adapter/pilot-executor slice passed `70/70` with `1` skipped; full MCP-discovery slice passed `279/279` with `9` skipped; build passed; graphify rebuilt to `5930 nodes`, `12884 edges`, `135 communities` diff --git a/docs/orchestration/address_truth_harness_phase84_business_overview_multi_probe_bridge.json b/docs/orchestration/address_truth_harness_phase84_business_overview_multi_probe_bridge.json new file mode 100644 index 0000000..5ef013c --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase84_business_overview_multi_probe_bridge.json @@ -0,0 +1,106 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase84_business_overview_multi_probe_bridge", + "domain": "address_phase84_business_overview_multi_probe_bridge", + "title": "Phase 84 business overview multi-probe bridge replay", + "description": "Targeted AGENT replay for the fresh business_overview MCP discovery bridge: broad company analysis must execute through reviewed multi-probe evidence, keep profit/margin boundaries honest, and not contaminate later exact counterparty pivots or repeated company-overview pivots.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_explicit_company_business_overview", + "title": "Explicit company overview uses the reviewed business_overview bridge", + "question": "Дай бизнес-обзор ООО Альтернатива Плюс по данным 1С: обороты, нетто, активность, что подтверждено и что пока неизвестно.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)1с|подтвержд", + "(?i)входящ|поступлен|оборот|денежн", + "(?i)исходящ|платеж|списан|поставщик", + "(?i)нетто|разниц|сальдо", + "(?i)прибыл|марж|не подтвержд|не доказан", + "(?i)ндс|vat|налог|долг|склад|inventory" + ], + "forbidden_answer_patterns": [ + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_", + "(?i)это прибыль|маржа составляет|чистая прибыль" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_multi_probe", + "broad_business_evaluation", + "planner_catalog_alignment", + "profit_margin_boundary" + ] + }, + { + "step_id": "step_02_exact_net_flow_after_company_overview", + "title": "Exact counterparty net-flow still answers after company overview", + "question": "Теперь отдельно: какое нетто по деньгам с Группа СВК за 2020 год, сколько получили и сколько заплатили?", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "value_flow_comparison", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)свк", + "(?i)2020|период", + "(?i)получил|входящ|поступлен", + "(?i)заплат|исходящ|списан|платеж", + "(?i)нетто|разниц|сальдо", + "(?i)руб" + ], + "forbidden_answer_patterns": [ + "(?i)активность компании", + "(?i)прибыль компании", + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_" + ], + "criticality": "critical", + "semantic_tags": [ + "counterparty_net_cash_flow", + "post_broad_eval_exact_pivot", + "planner_catalog_alignment" + ] + }, + { + "step_id": "step_03_explicit_company_overview_after_counterparty_pivot", + "title": "Repeated company overview does not inherit the counterparty pivot", + "question": "Вернись к ООО Альтернатива Плюс в целом: дай краткий бизнес-аудит по подтвержденным данным 1С, но не выдавай нетто за прибыль.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)альтернатива|компани|организац", + "(?i)1с|подтвержд", + "(?i)нетто|денежн|поток", + "(?i)не прибыль|не является прибыл|не марж|марж.*не подтвержд", + "(?i)следующ|нужно|не подтвержд|отдельн" + ], + "forbidden_answer_patterns": [ + "(?i)группа свк.*бизнес-аудит", + "(?i)чистая прибыль", + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_after_counterparty_pivot", + "stale_scope_guard", + "planner_catalog_alignment", + "profit_margin_boundary" + ] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase85_business_overview_tax_family.json b/docs/orchestration/address_truth_harness_phase85_business_overview_tax_family.json new file mode 100644 index 0000000..8c207b1 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase85_business_overview_tax_family.json @@ -0,0 +1,78 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase85_business_overview_tax_family", + "domain": "address_phase85_business_overview_tax_family", + "title": "Phase 85 business overview VAT/tax fact-family replay", + "description": "Targeted replay for Business Overview Fact-Family Expansion: explicit-period company overview may include checked VAT/tax evidence, while all-time follow-up must not reuse the previous VAT period as confirmed tax position.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_explicit_period_business_overview_with_tax", + "title": "Explicit-period business overview includes checked VAT/tax family", + "question": "Дай бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: деньги, нетто, активность, НДС-позиция, что подтверждено и что пока неизвестно. Не выдавай нетто за прибыль.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)1с|подтвержд", + "(?i)2020", + "(?i)входящ|поступлен|денежн", + "(?i)исходящ|платеж|списан", + "(?i)ндс|vat", + "(?i)книга продаж|продаж", + "(?i)книга покуп|вычет|покуп", + "(?i)нетто", + "(?i)не прибыль|не марж|прибыль.*не подтвержд|марж.*не подтвержд" + ], + "forbidden_answer_patterns": [ + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_", + "(?i)чистая прибыль|маржа составляет|это прибыль" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_tax_family", + "explicit_period_tax_position", + "planner_catalog_alignment", + "profit_margin_boundary" + ] + }, + { + "step_id": "step_02_all_time_business_overview_does_not_reuse_tax_period", + "title": "All-time overview after explicit tax period keeps VAT unknown", + "question": "Теперь по ООО Альтернатива Плюс за все доступное время дай бизнес-обзор в целом, но не тащи НДС за 2020 как подтвержденную общую налоговую позицию.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)альтернатива|организац|компани", + "(?i)все доступн|проверенн.*окн|активност", + "(?i)денежн|нетто", + "(?i)ндс|vat|налог", + "(?i)не подтвержд|нужен отдельн|явн.*период" + ], + "forbidden_answer_patterns": [ + "(?i)ндс-позиция за 2020", + "(?i)книга продаж.*2020", + "(?i)книга покуп.*2020", + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_tax_family", + "stale_period_guard", + "all_time_tax_boundary", + "planner_catalog_alignment" + ] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase86_business_overview_debt_position.json b/docs/orchestration/address_truth_harness_phase86_business_overview_debt_position.json new file mode 100644 index 0000000..4e62e54 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase86_business_overview_debt_position.json @@ -0,0 +1,79 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase86_business_overview_debt_position", + "domain": "address_phase86_business_overview_debt_position", + "title": "Phase 86 business overview debt-position fact-family replay", + "description": "Targeted replay for Business Overview Fact-Family Expansion: explicit-period company overview may include checked receivables/payables as-of-date debt position, while all-time follow-up must not reuse the previous as-of-date debt snapshot as current or all-time debt quality.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_explicit_period_business_overview_with_debt_position", + "title": "Explicit-period business overview includes checked receivables/payables debt position", + "question": "Дай бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: деньги, нетто, активность, дебиторка и кредиторка на дату, что подтверждено и что пока неизвестно. Не выдавай долговой срез за просрочку, качество долга или прибыль.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)1с|подтвержд", + "(?i)2020|2020-12-31", + "(?i)входящ|поступлен|денежн", + "(?i)исходящ|платеж|списан", + "(?i)дебитор|кредитор|долгов", + "(?i)нетто", + "(?i)не прибыль|не марж|прибыль.*не подтвержд|марж.*не подтвержд", + "(?i)качество.*не подтвержд|просроч.*не подтвержд|aging|due-date|не.*просроч" + ], + "forbidden_answer_patterns": [ + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_", + "(?i)просроченная дебиторка составляет|качество долга хорошее|кредитный риск низкий", + "(?i)чистая прибыль|маржа составляет|это прибыль" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_debt_position_family", + "explicit_period_as_of_debt_snapshot", + "debt_quality_boundary", + "profit_margin_boundary", + "planner_catalog_alignment" + ] + }, + { + "step_id": "step_02_all_time_business_overview_does_not_reuse_debt_snapshot", + "title": "All-time overview after explicit debt position keeps debt snapshot unknown", + "question": "Теперь по ООО Альтернатива Плюс за все доступное время дай бизнес-обзор в целом, но не тащи долговой срез на 2020-12-31 как текущую или общую долговую позицию.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)альтернатива|организац|компани", + "(?i)все доступн|проверенн.*окн|активност", + "(?i)денежн|нетто", + "(?i)дебитор|кредитор|долг", + "(?i)не подтвержд|нужен отдельн|явн.*дат|as-of|дату" + ], + "forbidden_answer_patterns": [ + "(?i)долгов(ой|ая).*2020-12-31", + "(?i)дебиторка.*2020-12-31", + "(?i)кредиторка.*2020-12-31", + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_debt_position_family", + "stale_as_of_debt_snapshot_guard", + "all_time_debt_boundary", + "planner_catalog_alignment" + ] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase87_business_overview_inventory_position.json b/docs/orchestration/address_truth_harness_phase87_business_overview_inventory_position.json new file mode 100644 index 0000000..da61f77 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase87_business_overview_inventory_position.json @@ -0,0 +1,76 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase87_business_overview_inventory_position", + "domain": "address_phase87_business_overview_inventory_position", + "title": "Phase 87 business overview inventory-position fact-family replay", + "description": "Targeted replay for Business Overview Fact-Family Expansion: explicit-date company overview may include checked inventory on-hand and purchase-date aging signals, while all-time follow-up must not reuse the previous inventory as-of-date snapshot as current or all-time warehouse health.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_explicit_date_business_overview_with_inventory_position", + "title": "Explicit-date business overview includes checked inventory position", + "question": "Дай бизнес-обзор ООО Альтернатива Плюс на 2026-04-16 по данным 1С: деньги, активность, складской остаток и товарный срез на дату, что подтверждено и что пока неизвестно. Не выдавай складской остаток за ликвидность, оборачиваемость, прибыль или полноценное здоровье бизнеса.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)1с|подтвержд", + "(?i)2026-04-16|16\\.04\\.2026", + "(?i)склад|остат|товар", + "(?i)руб|сумм|стоимост", + "(?i)не прибыль|не марж|прибыль.*не подтвержд|марж.*не подтвержд", + "(?i)оборачиваемость|ликвидность|не подтвержд|отдельн" + ], + "forbidden_answer_patterns": [ + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_", + "(?i)склад.*ликвидн.*хорош|оборачиваемость.*хорош|залежалость.*низк", + "(?i)чистая прибыль|маржа составляет|это прибыль" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_inventory_position_family", + "explicit_date_inventory_snapshot", + "inventory_liquidity_boundary", + "profit_margin_boundary", + "planner_catalog_alignment" + ] + }, + { + "step_id": "step_02_all_time_business_overview_does_not_reuse_inventory_snapshot", + "title": "All-time overview after explicit inventory position keeps inventory snapshot unknown", + "question": "Теперь по ООО Альтернатива Плюс за все доступное время дай бизнес-обзор в целом, но не тащи складской срез на 2026-04-16 как текущий или общий all-time склад.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)альтернатива|организац|компани", + "(?i)все доступн|проверенн.*окн|активност", + "(?i)денежн|нетто", + "(?i)склад|остат|inventory", + "(?i)не подтвержд|нужен отдельн|явн.*дат|дату" + ], + "forbidden_answer_patterns": [ + "(?i)склад(ской|ская|ские|).*2026-04-16", + "(?i)остат(ок|ки).*2026-04-16", + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_inventory_position_family", + "stale_as_of_inventory_snapshot_guard", + "all_time_inventory_boundary", + "planner_catalog_alignment" + ] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase88_business_overview_open_settlement_quality.json b/docs/orchestration/address_truth_harness_phase88_business_overview_open_settlement_quality.json new file mode 100644 index 0000000..95fdeb8 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase88_business_overview_open_settlement_quality.json @@ -0,0 +1,80 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase88_business_overview_open_settlement_quality", + "domain": "address_phase88_business_overview_open_settlement_quality", + "title": "Phase 88 business overview open-settlement quality replay", + "description": "Targeted replay for Business Overview Fact-Family Expansion: explicit-date company overview may include checked open-contract settlement concentration, while due-date aging/overdue debt must remain unclaimed and all-time follow-up must not reuse the prior as-of-date debt/open-contract snapshot.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_explicit_date_business_overview_with_open_settlement_quality", + "title": "Explicit-date business overview includes checked open-settlement quality boundary", + "question": "Дай бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: деньги, активность, НДС, дебиторка/кредиторка и качество открытых расчетов по договорам на дату. Отдельно скажи, что подтверждено, а что нельзя считать просрочкой или due-date aging без сроков оплаты.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)1с|подтвержд", + "(?i)2020|2020-12-31|31\\.12\\.2020", + "(?i)дебитор|кредитор|долгов", + "(?i)открыт.*расчет|открыт.*договор|договорн", + "(?i)руб|сумм|остат", + "(?i)due-date|срок|просроч|не подтвержд|нельзя считать" + ], + "forbidden_answer_patterns": [ + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_", + "(?i)подтвержденн(?:ая|ую|ый|ые)?\\s+просроч", + "(?i)просрочк[а-я\\s]{0,20}(?:есть|имеется|найдена|составляет)", + "(?i)долг.*плох|кредитн.*риск.*высок", + "(?i)чистая прибыль|маржа составляет|это прибыль" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_open_settlement_quality_family", + "explicit_date_debt_snapshot", + "open_contract_concentration_boundary", + "due_date_aging_boundary", + "planner_catalog_alignment" + ] + }, + { + "step_id": "step_02_all_time_business_overview_does_not_reuse_open_settlement_snapshot", + "title": "All-time overview after open-settlement quality keeps prior debt snapshot bounded", + "question": "Теперь по ООО Альтернатива Плюс за все доступное время дай общий бизнес-обзор, но не тащи срез открытых договоров на 2020-12-31 как текущую просрочку или all-time качество долга.", + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)альтернатива|организац|компани", + "(?i)все доступн|проверенн.*окн|активност", + "(?i)денежн|нетто", + "(?i)долг|дебитор|кредитор|открыт.*расчет", + "(?i)не подтвержд|нужен отдельн|явн.*дат|дату" + ], + "forbidden_answer_patterns": [ + "(?i)2020-12-31", + "(?i)31\\.12\\.2020", + "(?i)подтвержденн(?:ая|ую|ый|ые)?\\s+просроч", + "(?i)просрочк[а-я\\s]{0,20}(?:есть|имеется|найдена|составляет)", + "(?i)business_overview_route_template_v1", + "(?i)query_movements|query_documents|primitive|planner_|runtime_|pilot_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview_open_settlement_quality_family", + "stale_as_of_debt_snapshot_guard", + "all_time_debt_quality_boundary", + "planner_catalog_alignment" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index efe50c3..c5a611e 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -117,6 +117,9 @@ function isValueFlowPilot(pilot) { pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1" || pilot.pilot_scope === "counterparty_bidirectional_value_flow_query_movements_v1"); } +function isBusinessOverviewPilot(pilot) { + return pilot.pilot_scope === "business_overview_route_template_v1"; +} function isDocumentPilot(pilot) { return pilot.pilot_scope === "counterparty_document_evidence_query_documents_v1"; } @@ -333,6 +336,42 @@ function headlineFor(mode, pilot) { } return "Инвентарный route-template уже выбран, но live-исполнение этого generic MCP контура еще не подключено; складской/товарный факт не подтвержден."; } + if (isBusinessOverviewPilot(pilot) && pilot.derived_business_overview && mode === "confirmed_with_bounded_inference") { + const overview = pilot.derived_business_overview; + const families = []; + if (overview.incoming_customer_revenue.rows_with_amount > 0 || + overview.outgoing_supplier_payout.rows_with_amount > 0) { + families.push("денежный поток"); + } + if (overview.activity_period) { + families.push("активность"); + } + if (overview.tax_position) { + families.push("НДС-позиция"); + } + if (overview.debt_position) { + families.push("долговой срез на дату"); + } + if (overview.debt_open_settlement_quality) { + families.push("качество открытых расчетов"); + } + if (overview.inventory_position) { + families.push("складской срез на дату"); + } + const unknownFamilies = ["прибыль/маржа"]; + if (!overview.tax_position) { + unknownFamilies.push("НДС"); + } + if (!overview.debt_position) { + unknownFamilies.push("долговой срез"); + } + unknownFamilies.push(overview.debt_open_settlement_quality ? "due-date просрочка" : "качество открытых расчетов"); + unknownFamilies.push(overview.inventory_position ? "полноценная складская ликвидность" : "склад"); + return `По данным 1С собран ограниченный бизнес-обзор: ${families.join(", ")} подтверждены найденными строками; ${unknownFamilies.join(", ")} остаются отдельными непроверенными областями.`; + } + if (isBusinessOverviewPilot(pilot) && mode === "checked_sources_only") { + return "Бизнес-обзор был запущен, но подтвержденные денежные или activity-сигналы в найденных строках не получены."; + } if (isEntityResolutionPilot(pilot) && mode === "needs_clarification") { return "По каталогу 1С нашлось несколько похожих контрагентов, и без уточнения нельзя честно выбрать правильную сущность."; } @@ -469,6 +508,9 @@ function nextStepFor(mode, pilot) { } return "Следующий шаг - связать inventory route-template с exact inventory runtime и затем проверить live-прогоном."; } + if (mode === "confirmed_with_bounded_inference" && isBusinessOverviewPilot(pilot)) { + return "Если нужен уже управленческий вывод, следующим шагом стоит отдельно проверить прибыль/маржу, долги, НДС и складскую ликвидность, а затем собрать полный бизнес-аудит."; + } if (mode === "confirmed_with_bounded_inference" && pilot.derived_metadata_surface) { const surface = pilot.derived_metadata_surface; if (surface.ambiguity_detected && surface.ambiguity_entity_sets.length > 0) { @@ -500,6 +542,14 @@ function buildMustNotClaim(pilot) { claims.push("Do not claim full all-time turnover unless the checked period and coverage prove it."); claims.push("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows."); } + if (isBusinessOverviewPilot(pilot)) { + claims.push("Do not present business overview cash-flow spread as profit or margin."); + claims.push("Do not claim debt quality, VAT position, inventory health, or company health unless those contours were separately checked."); + claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis."); + claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt."); + claims.push("Do not present an inventory snapshot or purchase-date aging signal as turnover, obsolescence, liquidation value, or full inventory health."); + claims.push("Do not expose business_overview_route_template_v1 or MCP primitive names in the user answer."); + } if (pilot.derived_ranked_value_flow) { claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization."); claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist."); @@ -760,6 +810,95 @@ function derivedBidirectionalValueFlowMonthlyLines(pilot) { } return flow.monthly_breakdown.map((bucket) => `Помесячно: ${monthLabelRu(bucket.month_bucket)} — получили ${bucket.incoming_total_amount_human_ru}, заплатили ${bucket.outgoing_total_amount_human_ru}, ${netLabelRu(bucket.net_direction)} ${bucket.net_amount_human_ru}`); } +function businessOverviewNetDirectionRu(direction) { + if (direction === "net_incoming") { + return "операционный денежный поток в проверенном срезе больше входящий, чем исходящий"; + } + if (direction === "net_outgoing") { + return "операционный денежный поток в проверенном срезе больше исходящий, чем входящий"; + } + return "входящий и исходящий денежный поток в проверенном срезе примерно сбалансированы"; +} +function derivedBusinessOverviewConfirmedLines(pilot) { + const overview = pilot.derived_business_overview; + if (!overview) { + return []; + } + const organization = overview.organization_scope ? ` по организации ${overview.organization_scope}` : ""; + const period = overview.period_scope ? ` за ${overview.period_scope}` : " за все доступное проверенное окно"; + const lines = []; + if (overview.incoming_customer_revenue.rows_with_amount > 0) { + lines.push(`Входящие поступления${organization}${period}: ${overview.incoming_customer_revenue.total_amount_human_ru} по ${overview.incoming_customer_revenue.rows_with_amount} строкам с суммой.`); + } + if (overview.outgoing_supplier_payout.rows_with_amount > 0) { + lines.push(`Исходящие платежи/списания${organization}${period}: ${overview.outgoing_supplier_payout.total_amount_human_ru} по ${overview.outgoing_supplier_payout.rows_with_amount} строкам с суммой.`); + } + const leader = overview.top_customers[0]; + if (leader) { + lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + } + if (overview.activity_period) { + lines.push(`Окно подтвержденной активности в 1С: ${overview.activity_period.first_activity_date} — ${overview.activity_period.latest_activity_date}; ориентировочно ${overview.activity_period.duration_human_ru}.`); + } + if (overview.tax_position) { + const taxDirection = overview.tax_position.net_vat_direction === "vat_to_pay" + ? "к уплате" + : overview.tax_position.net_vat_direction === "vat_to_recover_or_offset" + ? "к вычету/зачету" + : "сбалансирован"; + lines.push(`НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.`); + } + if (overview.debt_position) { + const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable" + ? "в пользу дебиторки" + : overview.debt_position.net_debt_position_direction === "net_payable" + ? "в сторону кредиторки" + : "сбалансировано"; + lines.push(`Долговой срез на ${overview.debt_position.as_of_date}: дебиторка ${overview.debt_position.receivables.total_amount_human_ru}, кредиторка ${overview.debt_position.payables.total_amount_human_ru}, нетто ${debtDirection} ${overview.debt_position.net_debt_position_amount_human_ru}.`); + } + if (overview.debt_open_settlement_quality) { + const quality = overview.debt_open_settlement_quality; + const topContract = quality.top_contracts[0]; + const topContractText = topContract + ? ` Крупнейший открытый договор: ${topContract.contract}${topContract.counterparty ? ` / ${topContract.counterparty}` : ""} — ${topContract.total_amount_human_ru}${topContract.share_of_gross_open_amount_pct === null ? "" : ` (${topContract.share_of_gross_open_amount_pct}%)`}.` + : ""; + lines.push(`Качество открытых расчетов на ${quality.as_of_date}: брутто открытых договорных остатков ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${topContractText}`); + } + if (overview.inventory_position) { + const leader = overview.inventory_position.top_items[0]; + const leaderText = leader + ? ` Крупнейшая подтвержденная позиция: ${leader.item} — ${leader.total_amount_human_ru}.` + : ""; + lines.push(`Складской срез на ${overview.inventory_position.as_of_date}: остаток ${overview.inventory_position.total_amount_human_ru} по ${overview.inventory_position.rows_with_amount} строкам с суммой и ${overview.inventory_position.rows_with_quantity} строкам с количеством.${leaderText}`); + if (overview.inventory_position.aging_signal?.oldest_purchase_date) { + const ageText = overview.inventory_position.aging_signal.max_age_days === null + ? "" + : `, максимальный возраст сигнала ${overview.inventory_position.aging_signal.max_age_days} дн.`; + lines.push(`Возрастной сигнал склада: самая ранняя найденная дата закупки ${overview.inventory_position.aging_signal.oldest_purchase_date}${ageText}.`); + } + } + return lines; +} +function derivedBusinessOverviewInferenceLine(pilot) { + const overview = pilot.derived_business_overview; + if (!overview) { + return null; + } + if (overview.incoming_customer_revenue.rows_with_amount <= 0 && + overview.outgoing_supplier_payout.rows_with_amount <= 0) { + return null; + } + return [ + `Расчетное нетто по найденным строкам: ${overview.net_amount_human_ru}; ${businessOverviewNetDirectionRu(overview.net_direction)}.`, + "Это нормальный операционный сигнал, но не прибыль и не маржа: для управленческого вывода нужны отдельные расходы, себестоимость, долги, налоги и склад." + ].join(" "); +} +function businessOverviewUnknownLines(pilot) { + if (!pilot.derived_business_overview) { + return userFacingUnknowns(pilot.evidence.unknown_facts); + } + return userFacingUnknowns(pilot.evidence.unknown_facts); +} function buildAssistantMcpDiscoveryAnswerDraft(pilot) { const mode = modeFor(pilot); const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes]; @@ -770,7 +909,8 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { if (pilot.evidence.inferred_facts.length > 0) { pushReason(reasonCodes, "answer_contains_bounded_inference"); } - const derivedInferenceLine = derivedActivityInferenceLine(pilot) ?? + const derivedInferenceLine = derivedBusinessOverviewInferenceLine(pilot) ?? + derivedActivityInferenceLine(pilot) ?? derivedMetadataInferenceLine(pilot) ?? derivedRankedValueFlowInferenceLine(pilot) ?? derivedEntityResolutionInferenceLine(pilot); @@ -785,23 +925,43 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { const monthlyConfirmedLines = derivedBidirectionalValueFlowMonthlyLines(pilot).length > 0 ? derivedBidirectionalValueFlowMonthlyLines(pilot) : derivedValueFlowMonthlyLines(pilot); + const businessOverviewLines = derivedBusinessOverviewConfirmedLines(pilot); if (monthlyConfirmedLines.length > 0) { pushReason(reasonCodes, "answer_contains_monthly_breakdown"); } - const confirmedLines = pilot.derived_ranked_value_flow && derivedValueLine - ? [derivedValueLine] - : derivedValueLine - ? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines] - : derivedEntityResolutionLine - ? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine] - : derivedMetadataLine - ? [derivedMetadataLine] - : pilot.evidence.confirmed_facts; - const unknownLines = pilot.derived_metadata_surface - ? pilot.derived_metadata_surface.available_fields.length > 0 - ? userFacingUnknowns(pilot.evidence.unknown_facts) - : ["Детальный список полей этих объектов этим шагом не получен."] - : rankedValueFlowUnknownLines(pilot); + if (businessOverviewLines.length > 0) { + pushReason(reasonCodes, "answer_contains_business_overview"); + } + if (pilot.derived_business_overview?.tax_position) { + pushReason(reasonCodes, "answer_contains_business_overview_tax_position"); + } + if (pilot.derived_business_overview?.debt_position) { + pushReason(reasonCodes, "answer_contains_business_overview_debt_position"); + } + if (pilot.derived_business_overview?.debt_open_settlement_quality) { + pushReason(reasonCodes, "answer_contains_business_overview_open_settlement_quality"); + } + if (pilot.derived_business_overview?.inventory_position) { + pushReason(reasonCodes, "answer_contains_business_overview_inventory_position"); + } + const confirmedLines = businessOverviewLines.length > 0 + ? businessOverviewLines + : pilot.derived_ranked_value_flow && derivedValueLine + ? [derivedValueLine] + : derivedValueLine + ? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines] + : derivedEntityResolutionLine + ? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine] + : derivedMetadataLine + ? [derivedMetadataLine] + : pilot.evidence.confirmed_facts; + const unknownLines = pilot.derived_business_overview + ? businessOverviewUnknownLines(pilot) + : pilot.derived_metadata_surface + ? pilot.derived_metadata_surface.available_fields.length > 0 + ? userFacingUnknowns(pilot.evidence.unknown_facts) + : ["Детальный список полей этих объектов этим шагом не получен."] + : rankedValueFlowUnknownLines(pilot); return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryAnswerAdapter", diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index f1e9734..d0ef834 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -160,6 +160,51 @@ function buildValueFlowFilters(planner) { sort: "period_asc" }; } +function buildBusinessOverviewTaxFilters(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const periodFilters = dateScopeToFilters(dateScope); + if (!periodFilters.period_from || !periodFilters.period_to) { + return null; + } + return { + ...periodFilters, + ...(organization ? { organization } : {}) + }; +} +function buildBusinessOverviewDebtFilters(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const asOfDate = asOfDateFromDateScope(dateScope); + if (!asOfDate) { + return null; + } + return { + ...dateScopeToFilters(dateScope), + as_of_date: asOfDate, + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} +function buildBusinessOverviewInventoryFilters(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const asOfDate = asOfDateFromDateScope(dateScope); + if (!asOfDate) { + return null; + } + return { + ...dateScopeToFilters(dateScope), + as_of_date: asOfDate, + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} function buildInventoryExactFilters(planner) { const meaning = planner.discovery_plan.turn_meaning_ref; const subject = firstEntityCandidate(planner); @@ -395,6 +440,9 @@ function isValueFlowPilotEligible(planner) { combined.includes("payout") || combined.includes("value"))); } +function isBusinessOverviewPilotEligible(planner) { + return planner.selected_chain_id === "business_overview"; +} function isInventoryPilotEligible(planner) { return (planner.selected_chain_id === "inventory_stock_snapshot" || planner.selected_chain_id === "inventory_supplier_overlap" || @@ -1403,9 +1451,86 @@ function rowWarehouseValue(row) { function rowDocumentValue(row) { return rowTextValue(row, ["Регистратор", "Registrator", "registrator", "Документ", "Document", "document"]); } +function rowAccountValue(row) { + return rowTextValue(row, ["СчетДт", "AccountDt", "account_dt", "Счет", "Account", "account"]); +} function rowQuantityValue(row) { return rowNumberValue(row, ["Количество", "Quantity", "quantity", "Qty", "qty", "Остаток", "Balance", "balance"]); } +function rowAnalyticsTextValues(row) { + const values = []; + const analytics = row["analytics"]; + if (Array.isArray(analytics)) { + for (const item of analytics) { + const text = toNonEmptyString(item); + if (text && !values.includes(text)) { + values.push(text); + } + } + } + for (const key of [ + "СубконтоДт1", + "СубконтоДт2", + "СубконтоДт3", + "СубконтоКт1", + "СубконтоКт2", + "СубконтоКт3", + "SubcontoDt1", + "SubcontoDt2", + "SubcontoDt3", + "SubcontoKt1", + "SubcontoKt2", + "SubcontoKt3" + ]) { + const text = toNonEmptyString(row[key]); + if (text && !values.includes(text)) { + values.push(text); + } + } + return values; +} +function isEmptyAnalyticToken(value) { + return /^(?:0|<пусто>|пустая ссылка)$/iu.test(value.trim()); +} +function isLikelyContractToken(value) { + const normalized = value.trim(); + if (!normalized || isEmptyAnalyticToken(normalized)) { + return false; + } + if (/(?:договор|contract|дог\.)/iu.test(normalized)) { + return true; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + return false; + } + return normalized.length >= 3 && /[\\/]/.test(normalized); +} +function isLikelyCounterpartyToken(value) { + const normalized = value.trim(); + if (!normalized || isEmptyAnalyticToken(normalized)) { + return false; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + return false; + } + if (/^\d+(?:[./-]\d+)*$/.test(normalized)) { + return false; + } + if (!/[a-zа-я]/iu.test(normalized)) { + return false; + } + if (/(?:договор|contract|дог\.|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|банк|касса|movement|invoice|payment)/iu.test(normalized)) { + return false; + } + return true; +} +function rowContractValue(row) { + const explicit = rowTextValue(row, ["Договор", "Contract", "contract"]); + if (explicit && !isEmptyAnalyticToken(explicit)) { + return explicit; + } + return rowAnalyticsTextValues(row).find(isLikelyContractToken) ?? null; +} function rowCounterpartyValue(row) { const candidates = [row["Контрагент"], row["Counterparty"], row["counterparty"], row["Наименование"], row["name"]]; for (const candidate of candidates) { @@ -1414,7 +1539,7 @@ function rowCounterpartyValue(row) { return text; } } - return null; + return rowAnalyticsTextValues(row).find(isLikelyCounterpartyToken) ?? null; } function monthBucketFromIsoDate(isoDate) { const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/); @@ -1729,6 +1854,568 @@ function summarizeBidirectionalValueFlowRows(input) { : `${outgoing?.fetched_rows ?? 0} outgoing supplier-payout rows fetched, ${outgoing?.matched_rows ?? 0} matched`; return `${incomingSummary}; ${outgoingSummary}`; } +function deriveBusinessOverviewTaxPosition(result, periodScope) { + if (!result || result.error || result.matched_rows <= 0 || !periodScope) { + return null; + } + let salesVatAmount = 0; + let purchaseVatAmount = 0; + let rowsWithAmount = 0; + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + const marker = String(rowDocumentValue(row) ?? "").toLowerCase(); + const account = String(rowAccountValue(row) ?? "").toLowerCase(); + if (marker.includes("sales") || marker.includes("продаж") || account.startsWith("68")) { + salesVatAmount += amount; + rowsWithAmount += 1; + continue; + } + if (marker.includes("purchase") || marker.includes("покуп") || account.startsWith("19")) { + purchaseVatAmount += amount; + rowsWithAmount += 1; + } + } + if (rowsWithAmount <= 0) { + return null; + } + const netVatAmount = salesVatAmount - purchaseVatAmount; + const netVatDirection = netVatAmount > 0 + ? "vat_to_pay" + : netVatAmount < 0 + ? "vat_to_recover_or_offset" + : "balanced"; + return { + period_scope: periodScope, + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + sales_vat_amount: salesVatAmount, + sales_vat_amount_human_ru: formatAmountHumanRu(salesVatAmount), + purchase_vat_amount: purchaseVatAmount, + purchase_vat_amount_human_ru: formatAmountHumanRu(purchaseVatAmount), + net_vat_amount: netVatAmount, + net_vat_amount_human_ru: formatAmountHumanRu(Math.abs(netVatAmount)), + net_vat_direction: netVatDirection, + inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows" + }; +} +function deriveBusinessOverviewDebtSide(result) { + if (!result || result.error || result.matched_rows <= 0) { + return { + rows_matched: 0, + rows_with_amount: 0, + total_amount: 0, + total_amount_human_ru: formatAmountHumanRu(0), + top_counterparties: [] + }; + } + const buckets = new Map(); + let rowsWithAmount = 0; + let totalAmount = 0; + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + rowsWithAmount += 1; + totalAmount += amount; + const counterparty = rowCounterpartyValue(row) ?? "unknown_counterparty"; + const current = buckets.get(counterparty) ?? { rows_with_amount: 0, total_amount: 0 }; + current.rows_with_amount += 1; + current.total_amount += amount; + buckets.set(counterparty, current); + } + const topCounterparties = Array.from(buckets.entries()) + .map(([axisValue, bucket]) => ({ + axis_value: axisValue, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + return amountDelta !== 0 ? amountDelta : left.axis_value.localeCompare(right.axis_value, "ru"); + }) + .slice(0, 5); + return { + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + total_amount: totalAmount, + total_amount_human_ru: formatAmountHumanRu(totalAmount), + top_counterparties: topCounterparties + }; +} +function deriveBusinessOverviewDebtPosition(input) { + if (!input.debtAsOfDate) { + return null; + } + const receivables = deriveBusinessOverviewDebtSide(input.receivablesResult); + const payables = deriveBusinessOverviewDebtSide(input.payablesResult); + if (receivables.rows_with_amount <= 0 && payables.rows_with_amount <= 0) { + return null; + } + const netDebtPositionAmount = receivables.total_amount - payables.total_amount; + const netDebtPositionDirection = netDebtPositionAmount > 0 + ? "net_receivable" + : netDebtPositionAmount < 0 + ? "net_payable" + : "balanced"; + return { + as_of_date: input.debtAsOfDate, + receivables, + payables, + net_debt_position_amount: netDebtPositionAmount, + net_debt_position_amount_human_ru: formatAmountHumanRu(Math.abs(netDebtPositionAmount)), + net_debt_position_direction: netDebtPositionDirection, + inference_basis: "receivables_minus_payables_confirmed_1c_balance_rows" + }; +} +function percentageOfTotal(part, total) { + if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) { + return null; + } + return Math.round((part / total) * 10_000) / 100; +} +function deriveBusinessOverviewDebtOpenSettlementQuality(input) { + if (!input.debtAsOfDate || !input.openContractsResult || input.openContractsResult.error || input.openContractsResult.matched_rows <= 0) { + return null; + } + const counterpartyBuckets = new Map(); + const contractBuckets = new Map(); + const counterparties = new Set(); + const contracts = new Set(); + let rowsWithAmount = 0; + let grossOpenAmount = 0; + let rowsWithoutCounterparty = 0; + let rowsWithoutContract = 0; + for (const row of input.openContractsResult.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + const absAmount = Math.abs(amount); + if (absAmount <= 0) { + continue; + } + rowsWithAmount += 1; + grossOpenAmount += absAmount; + const counterparty = rowCounterpartyValue(row); + const contract = rowContractValue(row); + if (counterparty) { + counterparties.add(counterparty); + const current = counterpartyBuckets.get(counterparty) ?? { rows_with_amount: 0, total_amount: 0 }; + current.rows_with_amount += 1; + current.total_amount += absAmount; + counterpartyBuckets.set(counterparty, current); + } + else { + rowsWithoutCounterparty += 1; + } + if (contract) { + contracts.add(contract); + const current = contractBuckets.get(contract) ?? { + counterparty, + rows_with_amount: 0, + total_amount: 0 + }; + if (!current.counterparty && counterparty) { + current.counterparty = counterparty; + } + current.rows_with_amount += 1; + current.total_amount += absAmount; + contractBuckets.set(contract, current); + } + else { + rowsWithoutContract += 1; + } + } + if (rowsWithAmount <= 0) { + return null; + } + const topCounterparties = Array.from(counterpartyBuckets.entries()) + .map(([axisValue, bucket]) => ({ + axis_value: axisValue, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + return amountDelta !== 0 ? amountDelta : left.axis_value.localeCompare(right.axis_value, "ru"); + }) + .slice(0, 5); + const topContracts = Array.from(contractBuckets.entries()) + .map(([contract, bucket]) => ({ + contract, + counterparty: bucket.counterparty, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount), + share_of_gross_open_amount_pct: percentageOfTotal(bucket.total_amount, grossOpenAmount) + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + return amountDelta !== 0 ? amountDelta : left.contract.localeCompare(right.contract, "ru"); + }) + .slice(0, 5); + return { + as_of_date: input.debtAsOfDate, + rows_matched: input.openContractsResult.matched_rows, + rows_with_amount: rowsWithAmount, + gross_open_amount: grossOpenAmount, + gross_open_amount_human_ru: formatAmountHumanRu(grossOpenAmount), + unique_counterparties: counterparties.size, + unique_contracts: contracts.size, + rows_without_counterparty: rowsWithoutCounterparty, + rows_without_contract: rowsWithoutContract, + top_counterparties: topCounterparties, + top_contracts: topContracts, + concentration_top_counterparty_pct: percentageOfTotal(topCounterparties[0]?.total_amount ?? 0, grossOpenAmount), + concentration_top_contract_pct: percentageOfTotal(topContracts[0]?.total_amount ?? 0, grossOpenAmount), + inference_basis: "open_contracts_confirmed_1c_balance_rows" + }; +} +function daysBetweenIsoDates(leftIsoDate, rightIsoDate) { + const leftMatch = leftIsoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + const rightMatch = rightIsoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!leftMatch || !rightMatch) { + return null; + } + const left = Date.UTC(Number(leftMatch[1]), Number(leftMatch[2]) - 1, Number(leftMatch[3])); + const right = Date.UTC(Number(rightMatch[1]), Number(rightMatch[2]) - 1, Number(rightMatch[3])); + if (!Number.isFinite(left) || !Number.isFinite(right)) { + return null; + } + return Math.max(0, Math.floor((right - left) / 86_400_000)); +} +function deriveBusinessOverviewInventoryAgingSignal(result, inventoryAsOfDate) { + if (!result || result.error || result.matched_rows <= 0) { + return null; + } + const dates = result.rows + .map((row) => rowDateValue(row)) + .filter((value) => Boolean(value)) + .sort((left, right) => left.localeCompare(right)); + if (dates.length <= 0) { + return null; + } + const oldestPurchaseDate = dates[0] ?? null; + const latestPurchaseDate = dates[dates.length - 1] ?? null; + return { + rows_matched: result.matched_rows, + rows_with_purchase_date: dates.length, + oldest_purchase_date: oldestPurchaseDate, + latest_purchase_date: latestPurchaseDate, + max_age_days: oldestPurchaseDate ? daysBetweenIsoDates(oldestPurchaseDate, inventoryAsOfDate) : null, + inference_basis: "inventory_purchase_dates_from_confirmed_1c_rows" + }; +} +function deriveBusinessOverviewInventoryPosition(input) { + const { inventoryAsOfDate, inventoryOnHandResult } = input; + if (!inventoryAsOfDate || !inventoryOnHandResult || inventoryOnHandResult.error || inventoryOnHandResult.matched_rows <= 0) { + return null; + } + const buckets = new Map(); + let rowsWithAmount = 0; + let rowsWithQuantity = 0; + let totalAmount = 0; + let totalQuantity = 0; + for (const row of inventoryOnHandResult.rows) { + const amount = rowAmountValue(row); + const quantity = rowQuantityValue(row); + if (amount === null && quantity === null) { + continue; + } + const item = rowInventoryItemValue(row) ?? "unknown_item"; + const current = buckets.get(item) ?? { + rows_with_amount: 0, + rows_with_quantity: 0, + total_amount: 0, + total_quantity: 0 + }; + if (amount !== null) { + rowsWithAmount += 1; + totalAmount += amount; + current.rows_with_amount += 1; + current.total_amount += amount; + } + if (quantity !== null) { + rowsWithQuantity += 1; + totalQuantity += quantity; + current.rows_with_quantity += 1; + current.total_quantity += quantity; + } + buckets.set(item, current); + } + if (rowsWithAmount <= 0 && rowsWithQuantity <= 0) { + return null; + } + const topItems = Array.from(buckets.entries()) + .map(([item, bucket]) => ({ + item, + rows_with_amount: bucket.rows_with_amount, + rows_with_quantity: bucket.rows_with_quantity, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount), + total_quantity: bucket.total_quantity + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + if (amountDelta !== 0) { + return amountDelta; + } + const quantityDelta = right.total_quantity - left.total_quantity; + return quantityDelta !== 0 ? quantityDelta : left.item.localeCompare(right.item, "ru"); + }) + .slice(0, 5); + return { + as_of_date: inventoryAsOfDate, + rows_matched: inventoryOnHandResult.matched_rows, + rows_with_amount: rowsWithAmount, + rows_with_quantity: rowsWithQuantity, + total_amount: totalAmount, + total_amount_human_ru: formatAmountHumanRu(totalAmount), + total_quantity: totalQuantity, + top_items: topItems, + aging_signal: deriveBusinessOverviewInventoryAgingSignal(input.inventoryAgingResult, inventoryAsOfDate), + inference_basis: "inventory_on_hand_confirmed_1c_balance_rows" + }; +} +function deriveBusinessOverview(input) { + const incoming = deriveValueFlowSideSummary(input.incomingResult); + const outgoing = deriveValueFlowSideSummary(input.outgoingResult); + const rankedIncoming = deriveRankedValueFlow(input.incomingResult, { + organizationScope: input.organizationScope, + periodScope: input.periodScope, + direction: "incoming_customer_revenue", + rankingNeed: "top_desc" + }); + const activityPeriod = deriveActivityPeriod(input.lifecycleResult); + const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope); + const debtPosition = deriveBusinessOverviewDebtPosition({ + receivablesResult: input.receivablesResult, + payablesResult: input.payablesResult, + debtAsOfDate: input.debtAsOfDate + }); + const debtOpenSettlementQuality = deriveBusinessOverviewDebtOpenSettlementQuality({ + openContractsResult: input.openContractsResult, + debtAsOfDate: input.debtAsOfDate + }); + const inventoryPosition = deriveBusinessOverviewInventoryPosition({ + inventoryOnHandResult: input.inventoryOnHandResult, + inventoryAgingResult: input.inventoryAgingResult, + inventoryAsOfDate: input.inventoryAsOfDate + }); + const checkedSignalCount = [ + incoming.rows_with_amount > 0, + outgoing.rows_with_amount > 0, + Boolean(activityPeriod), + Boolean(taxPosition), + Boolean(debtPosition), + Boolean(debtOpenSettlementQuality), + Boolean(inventoryPosition) + ].filter(Boolean).length; + if (checkedSignalCount <= 0) { + return null; + } + const netAmount = incoming.total_amount - outgoing.total_amount; + return { + organization_scope: input.organizationScope, + period_scope: input.periodScope, + incoming_customer_revenue: incoming, + outgoing_supplier_payout: outgoing, + net_amount: netAmount, + net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)), + net_direction: netDirectionFromAmount(netAmount), + top_customers: rankedIncoming?.ranked_values ?? [], + activity_period: activityPeriod, + tax_position: taxPosition, + debt_position: debtPosition, + debt_open_settlement_quality: debtOpenSettlementQuality, + inventory_position: inventoryPosition, + coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, + checked_signal_count: checkedSignalCount, + missing_signal_families: [ + "profit_margin", + debtPosition ? null : "debt_position", + debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", + taxPosition ? null : "tax_position", + inventoryPosition ? "inventory_turnover_quality" : "inventory_position", + inventoryPosition?.aging_signal ? null : "inventory_aging_quality" + ].filter((item) => Boolean(item)), + inference_basis: inventoryPosition + ? "business_overview_from_confirmed_1c_multi_family_rows" + : debtOpenSettlementQuality + ? "business_overview_from_confirmed_1c_multi_family_rows" + : taxPosition && debtPosition + ? "business_overview_from_confirmed_1c_money_activity_tax_and_debt_rows" + : taxPosition + ? "business_overview_from_confirmed_1c_money_activity_and_tax_rows" + : debtPosition + ? "business_overview_from_confirmed_1c_money_activity_and_debt_rows" + : "business_overview_from_confirmed_1c_money_and_activity_rows" + }; +} +function summarizeBusinessOverviewRows(input) { + const parts = []; + if (input.incomingResult && !input.incomingResult.error) { + parts.push(`${input.incomingResult.fetched_rows} incoming rows fetched, ${input.incomingResult.matched_rows} matched`); + } + if (input.outgoingResult && !input.outgoingResult.error) { + parts.push(`${input.outgoingResult.fetched_rows} outgoing rows fetched, ${input.outgoingResult.matched_rows} matched`); + } + if (input.lifecycleResult && !input.lifecycleResult.error) { + parts.push(`${input.lifecycleResult.fetched_rows} activity/document rows fetched, ${input.lifecycleResult.matched_rows} matched`); + } + if (input.taxResult && !input.taxResult.error) { + parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`); + } + if (input.receivablesResult && !input.receivablesResult.error) { + parts.push(`${input.receivablesResult.fetched_rows} receivables rows fetched, ${input.receivablesResult.matched_rows} matched`); + } + if (input.payablesResult && !input.payablesResult.error) { + parts.push(`${input.payablesResult.fetched_rows} payables rows fetched, ${input.payablesResult.matched_rows} matched`); + } + if (input.openContractsResult && !input.openContractsResult.error) { + parts.push(`${input.openContractsResult.fetched_rows} open-contract rows fetched, ${input.openContractsResult.matched_rows} matched`); + } + if (input.inventoryOnHandResult && !input.inventoryOnHandResult.error) { + parts.push(`${input.inventoryOnHandResult.fetched_rows} inventory on-hand rows fetched, ${input.inventoryOnHandResult.matched_rows} matched`); + } + if (input.inventoryAgingResult && !input.inventoryAgingResult.error) { + parts.push(`${input.inventoryAgingResult.fetched_rows} inventory aging rows fetched, ${input.inventoryAgingResult.matched_rows} matched`); + } + return parts.length > 0 ? parts.join("; ") : null; +} +function buildBusinessOverviewConfirmedFacts(derived) { + if (!derived) { + return []; + } + const facts = []; + const organization = derived.organization_scope ? ` по организации ${derived.organization_scope}` : ""; + const period = derived.period_scope ? ` за ${derived.period_scope}` : " за все доступное проверенное окно"; + if (derived.incoming_customer_revenue.rows_with_amount > 0) { + facts.push(`В 1С подтверждены входящие поступления${organization}${period}: ${derived.incoming_customer_revenue.total_amount_human_ru} по ${derived.incoming_customer_revenue.rows_with_amount} строкам с суммой.`); + } + if (derived.outgoing_supplier_payout.rows_with_amount > 0) { + facts.push(`В 1С подтверждены исходящие платежи/списания${organization}${period}: ${derived.outgoing_supplier_payout.total_amount_human_ru} по ${derived.outgoing_supplier_payout.rows_with_amount} строкам с суммой.`); + } + if (derived.top_customers.length > 0) { + const leader = derived.top_customers[0]; + facts.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + } + if (derived.activity_period) { + facts.push(`Подтвержденное окно активности в 1С: ${derived.activity_period.first_activity_date} — ${derived.activity_period.latest_activity_date}.`); + } + if (derived.tax_position) { + const taxDirection = derived.tax_position.net_vat_direction === "vat_to_pay" + ? "к уплате" + : derived.tax_position.net_vat_direction === "vat_to_recover_or_offset" + ? "к вычету/зачету" + : "сбалансирован"; + facts.push(`НДС-позиция за ${derived.tax_position.period_scope} подтверждена по книгам продаж/покупок: продажи ${derived.tax_position.sales_vat_amount_human_ru}, покупки/вычеты ${derived.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${derived.tax_position.net_vat_amount_human_ru}.`); + } + if (derived.debt_position) { + const debtDirection = derived.debt_position.net_debt_position_direction === "net_receivable" + ? "в пользу дебиторки" + : derived.debt_position.net_debt_position_direction === "net_payable" + ? "в сторону кредиторки" + : "сбалансировано"; + facts.push(`Долговая позиция на ${derived.debt_position.as_of_date} подтверждена по срезам дебиторки/кредиторки 1С: дебиторка ${derived.debt_position.receivables.total_amount_human_ru}, кредиторка ${derived.debt_position.payables.total_amount_human_ru}, нетто ${debtDirection} ${derived.debt_position.net_debt_position_amount_human_ru}.`); + } + if (derived.debt_open_settlement_quality) { + const quality = derived.debt_open_settlement_quality; + const leader = quality.top_contracts[0]; + const leaderShareText = leader?.share_of_gross_open_amount_pct === null || leader?.share_of_gross_open_amount_pct === undefined + ? "" + : ` (${leader.share_of_gross_open_amount_pct}%)`; + const leaderText = leader + ? ` Крупнейший открытый договор: ${leader.contract}${leader.counterparty ? ` / ${leader.counterparty}` : ""} — ${leader.total_amount_human_ru}${leaderShareText}.` + : ""; + facts.push(`Качество открытых расчетов на ${quality.as_of_date} проверено по договорным остаткам 60/62/76: брутто ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${leaderText}`); + } + if (derived.inventory_position) { + const leader = derived.inventory_position.top_items[0]; + const leaderText = leader + ? ` Крупнейшая подтвержденная позиция: ${leader.item} — ${leader.total_amount_human_ru}.` + : ""; + facts.push(`Складской срез на ${derived.inventory_position.as_of_date} подтвержден по 1С: остаток ${derived.inventory_position.total_amount_human_ru} по ${derived.inventory_position.rows_with_amount} строкам с суммой и ${derived.inventory_position.rows_with_quantity} строкам с количеством.${leaderText}`); + if (derived.inventory_position.aging_signal?.oldest_purchase_date) { + const ageText = derived.inventory_position.aging_signal.max_age_days === null + ? "" + : `, максимальный возраст сигнала ${derived.inventory_position.aging_signal.max_age_days} дн.`; + facts.push(`Возрастной сигнал склада подтвержден по найденным строкам закупок: самая ранняя дата ${derived.inventory_position.aging_signal.oldest_purchase_date}${ageText}.`); + } + } + return facts; +} +function buildBusinessOverviewInferredFacts(derived) { + if (!derived) { + return []; + } + if (derived.incoming_customer_revenue.rows_with_amount <= 0 && + derived.outgoing_supplier_payout.rows_with_amount <= 0) { + return []; + } + const direction = derived.net_direction === "net_incoming" + ? "денежный поток в проверенном срезе больше входящий, чем исходящий" + : derived.net_direction === "net_outgoing" + ? "денежный поток в проверенном срезе больше исходящий, чем входящий" + : "входящий и исходящий денежный поток в проверенном срезе примерно сбалансированы"; + return [ + `Расчетное нетто по найденным строкам: ${derived.net_amount_human_ru}; ${direction}.`, + "Это операционный денежный сигнал по найденным строкам 1С, а не прибыль, маржа или бухгалтерское заключение о здоровье бизнеса." + ]; +} +function buildBusinessOverviewUnknownFacts(derived) { + const missing = new Set(derived?.missing_signal_families ?? [ + "profit_margin", + "debt_position", + "tax_position", + "inventory_position" + ]); + const unknowns = [ + missing.has("profit_margin") + ? "Прибыль и маржа этим бизнес-обзором не подтверждены: нужны себестоимость, расходы и закрывающие документы." + : null, + missing.has("debt_quality") + ? "Качество дебиторки/кредиторки этим бизнес-обзором не подтверждено: нужен отдельный долговой срез." + : null, + missing.has("debt_position") + ? "Дебиторка/кредиторка этим бизнес-обзором не подтверждены: нужен отдельный долговой срез на явную дату." + : null, + missing.has("debt_aging_quality") + ? "Качество долга и просрочка этим бизнес-обзором не подтверждены: текущий долговой срез показывает только суммы на дату, без aging/due-date анализа." + : null, + missing.has("debt_open_settlement_quality") + ? "Качество открытых расчетов этим бизнес-обзором не подтверждено: нужен срез открытых договоров на явную дату." + : null, + missing.has("debt_due_date_aging_quality") + ? "Просрочка и due-date aging этим бизнес-обзором не подтверждены: открытые договоры показывают концентрацию остатков, но не договорные сроки оплаты." + : null, + missing.has("tax_position") + ? "Налоговая/VAT-позиция этим бизнес-обзором не подтверждена: нужен отдельный налоговый контур или явный проверяемый период." + : null, + missing.has("inventory_health") + ? "Складская ликвидность и товарные остатки этим бизнес-обзором не подтверждены: нужен отдельный inventory-срез." + : null, + missing.has("inventory_position") + ? "Складской остаток этим бизнес-обзором не подтвержден: нужен отдельный inventory-срез на явную дату." + : null, + missing.has("inventory_aging_quality") + ? "Возраст, залежалость и ликвидность склада этим бизнес-обзором не подтверждены: текущий складской срез показывает только остаток на дату без полноценной оборачиваемости." + : null, + missing.has("inventory_turnover_quality") + ? "Скорость продаж, оборачиваемость и ликвидность склада этим бизнес-обзором не подтверждены: нужен отдельный inventory/продажный анализ, а не только остаток на дату." + : null + ].filter((item) => Boolean(item)); + if (derived?.coverage_limited_by_probe_limit) { + unknowns.unshift("Полное покрытие бизнес-обзора не подтверждено: хотя бы один денежный probe достиг лимита строк."); + } + return unknowns; +} function buildLifecycleConfirmedFacts(result, counterparty) { if (result.error || result.matched_rows <= 0) { return []; @@ -2140,6 +2827,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const movementPilotEligible = isMovementEvidencePilotEligible(planner); const lifecyclePilotEligible = isLifecyclePilotEligible(planner); const valueFlowPilotEligible = isValueFlowPilotEligible(planner); + const businessOverviewPilotEligible = isBusinessOverviewPilotEligible(planner); const entityResolutionPilotEligible = isEntityResolutionPilotEligible(planner); const inventoryPilotEligible = isInventoryPilotEligible(planner); if (!metadataPilotEligible && @@ -2147,6 +2835,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { !movementPilotEligible && !lifecyclePilotEligible && !valueFlowPilotEligible && + !businessOverviewPilotEligible && !entityResolutionPilotEligible && !inventoryPilotEligible) { pushReason(reasonCodes, "pilot_scope_unsupported_for_live_execution"); @@ -2314,6 +3003,369 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { reason_codes: reasonCodes }; } + if (businessOverviewPilotEligible) { + let incomingResult = null; + let outgoingResult = null; + let lifecycleResult = null; + let taxResult = null; + let receivablesResult = null; + let payablesResult = null; + let openContractsResult = null; + let inventoryOnHandResult = null; + let inventoryAgingResult = null; + const valueFilters = buildValueFlowFilters(planner); + const lifecycleFilters = buildLifecycleFilters(planner); + const taxFilters = buildBusinessOverviewTaxFilters(planner); + const debtFilters = buildBusinessOverviewDebtFilters(planner); + const inventoryFilters = buildBusinessOverviewInventoryFilters(planner); + const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date); + const inventoryAsOfDate = toNonEmptyString(inventoryFilters?.as_of_date); + const incomingSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("customer_revenue_and_payments", valueFilters); + const outgoingSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("supplier_payouts_profile", valueFilters); + const lifecycleSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("counterparty_activity_lifecycle", lifecycleFilters); + const taxSelection = taxFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("vat_liability_confirmed_for_tax_period", taxFilters) + : null; + const receivablesSelection = debtFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("receivables_confirmed_as_of_date", debtFilters) + : null; + const payablesSelection = debtFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("payables_confirmed_as_of_date", debtFilters) + : null; + const openContractsSelection = debtFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("open_contracts_confirmed_as_of_date", debtFilters) + : null; + const inventoryOnHandSelection = inventoryFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("inventory_on_hand_as_of_date", inventoryFilters) + : null; + const inventoryAgingSelection = inventoryFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("inventory_aging_by_purchase_date", inventoryFilters) + : null; + if (!incomingSelection.selected_recipe || !outgoingSelection.selected_recipe || !lifecycleSelection.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_recipe_not_available"); + const missing = [ + incomingSelection.selected_recipe ? null : "customer_revenue_and_payments", + outgoingSelection.selected_recipe ? null : "supplier_payouts_profile", + lifecycleSelection.selected_recipe ? null : "counterparty_activity_lifecycle" + ].filter((item) => Boolean(item)); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "Business overview recipe is not available"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "business_overview_route_template_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + derived_metadata_surface: null, + derived_entity_resolution: null, + derived_activity_period: null, + derived_value_flow: null, + derived_bidirectional_value_flow: null, + query_limitations: [`Business overview recipe is not available: ${missing.join(", ")}`], + reason_codes: reasonCodes + }; + } + pushReason(reasonCodes, "pilot_business_overview_recipes_selected"); + if (taxSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_tax_recipe_selected"); + } + else if (!taxFilters) { + pushReason(reasonCodes, "pilot_business_overview_tax_probe_skipped_without_explicit_period"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available"); + pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe"); + } + if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_debt_recipes_selected"); + } + else if (!debtFilters) { + pushReason(reasonCodes, "pilot_business_overview_debt_probe_skipped_without_explicit_as_of_date"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_debt_recipe_not_available"); + pushUnique(queryLimitations, "Business overview debt-position probe requires executable receivables/payables as-of-date recipes"); + } + if (openContractsSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_selected"); + } + else if (!debtFilters) { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_probe_skipped_without_explicit_as_of_date"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_not_available"); + pushUnique(queryLimitations, "Business overview open-settlement quality probe requires executable open-contracts as-of-date recipe"); + } + if (inventoryOnHandSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_recipe_selected"); + if (inventoryAgingSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_inventory_aging_recipe_selected"); + } + } + else if (!inventoryFilters) { + pushReason(reasonCodes, "pilot_business_overview_inventory_probe_skipped_without_explicit_as_of_date"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_inventory_recipe_not_available"); + pushUnique(queryLimitations, "Business overview inventory-position probe requires an executable inventory on-hand as-of-date recipe"); + } + for (const step of dryRun.execution_steps) { + if (step.primitive_id === "query_movements") { + const incomingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => (0, addressRecipeCatalog_1.buildAddressRecipePlan)(incomingSelection.selected_recipe, scopedFilters), + baseFilters: valueFilters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps: runtimeDeps + }); + const outgoingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => (0, addressRecipeCatalog_1.buildAddressRecipePlan)(outgoingSelection.selected_recipe, scopedFilters), + baseFilters: valueFilters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps: runtimeDeps + }); + incomingResult = incomingExecution.result; + outgoingResult = outgoingExecution.result; + if (taxSelection?.selected_recipe) { + const taxPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(taxSelection.selected_recipe, taxFilters); + taxResult = await runtimeDeps.executeAddressMcpQuery({ + query: taxPlan.query, + limit: taxPlan.limit, + account_scope: taxPlan.account_scope + }); + } + if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { + const receivablesPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(receivablesSelection.selected_recipe, debtFilters); + receivablesResult = await runtimeDeps.executeAddressMcpQuery({ + query: receivablesPlan.query, + limit: receivablesPlan.limit, + account_scope: receivablesPlan.account_scope + }); + const payablesPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(payablesSelection.selected_recipe, debtFilters); + payablesResult = await runtimeDeps.executeAddressMcpQuery({ + query: payablesPlan.query, + limit: payablesPlan.limit, + account_scope: payablesPlan.account_scope + }); + } + if (openContractsSelection?.selected_recipe) { + const openContractsPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(openContractsSelection.selected_recipe, debtFilters); + openContractsResult = await runtimeDeps.executeAddressMcpQuery({ + query: openContractsPlan.query, + limit: openContractsPlan.limit, + account_scope: openContractsPlan.account_scope + }); + } + if (inventoryOnHandSelection?.selected_recipe) { + const inventoryOnHandPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(inventoryOnHandSelection.selected_recipe, inventoryFilters); + inventoryOnHandResult = await runtimeDeps.executeAddressMcpQuery({ + query: inventoryOnHandPlan.query, + limit: inventoryOnHandPlan.limit, + account_scope: inventoryOnHandPlan.account_scope + }); + if (inventoryAgingSelection?.selected_recipe) { + const inventoryAgingPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(inventoryAgingSelection.selected_recipe, inventoryFilters); + inventoryAgingResult = await runtimeDeps.executeAddressMcpQuery({ + query: inventoryAgingPlan.query, + limit: inventoryAgingPlan.limit, + account_scope: inventoryAgingPlan.account_scope + }); + } + } + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(...incomingExecution.probe_results, ...outgoingExecution.probe_results); + if (taxResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, taxResult)); + } + if (receivablesResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, receivablesResult)); + } + if (payablesResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, payablesResult)); + } + if (openContractsResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, openContractsResult)); + } + if (inventoryOnHandResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryOnHandResult)); + } + if (inventoryAgingResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryAgingResult)); + } + for (const limitation of [...incomingExecution.query_limitations, ...outgoingExecution.query_limitations]) { + pushUnique(queryLimitations, limitation); + } + if (incomingResult?.error) { + pushReason(reasonCodes, "pilot_business_overview_incoming_query_mcp_error"); + } + if (outgoingResult?.error) { + pushReason(reasonCodes, "pilot_business_overview_outgoing_query_mcp_error"); + } + if (!incomingResult?.error || !outgoingResult?.error) { + pushReason(reasonCodes, "pilot_business_overview_query_movements_mcp_executed"); + } + if (taxResult?.error) { + pushUnique(queryLimitations, taxResult.error); + pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_error"); + } + else if (taxResult) { + pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_executed"); + } + if (receivablesResult?.error) { + pushUnique(queryLimitations, receivablesResult.error); + pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_error"); + } + else if (receivablesResult) { + pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_executed"); + } + if (payablesResult?.error) { + pushUnique(queryLimitations, payablesResult.error); + pushReason(reasonCodes, "pilot_business_overview_payables_query_mcp_error"); + } + else if (payablesResult) { + pushReason(reasonCodes, "pilot_business_overview_payables_query_mcp_executed"); + } + if ((receivablesResult && !receivablesResult.error) || + (payablesResult && !payablesResult.error)) { + pushReason(reasonCodes, "pilot_business_overview_debt_query_mcp_executed"); + } + if (openContractsResult?.error) { + pushUnique(queryLimitations, openContractsResult.error); + pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_error"); + } + else if (openContractsResult) { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_executed"); + } + if (inventoryOnHandResult?.error) { + pushUnique(queryLimitations, inventoryOnHandResult.error); + pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_error"); + } + else if (inventoryOnHandResult) { + pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_executed"); + } + if (inventoryAgingResult?.error) { + pushUnique(queryLimitations, inventoryAgingResult.error); + pushReason(reasonCodes, "pilot_business_overview_inventory_aging_query_mcp_error"); + } + else if (inventoryAgingResult) { + pushReason(reasonCodes, "pilot_business_overview_inventory_aging_query_mcp_executed"); + } + if ((inventoryOnHandResult && !inventoryOnHandResult.error) || + (inventoryAgingResult && !inventoryAgingResult.error)) { + pushReason(reasonCodes, "pilot_business_overview_inventory_query_mcp_executed"); + } + continue; + } + if (step.primitive_id === "query_documents") { + const lifecyclePlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(lifecycleSelection.selected_recipe, lifecycleFilters); + lifecycleResult = await runtimeDeps.executeAddressMcpQuery({ + query: lifecyclePlan.query, + limit: lifecyclePlan.limit, + account_scope: lifecyclePlan.account_scope + }); + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, lifecycleResult)); + if (lifecycleResult.error) { + pushUnique(queryLimitations, lifecycleResult.error); + pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_error"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_executed"); + } + continue; + } + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_business_overview_derives_aggregate_coverage_and_explanation")); + } + const derivedBusinessOverview = deriveBusinessOverview({ + incomingResult, + outgoingResult, + lifecycleResult, + taxResult, + receivablesResult, + payablesResult, + openContractsResult, + debtAsOfDate, + inventoryOnHandResult, + inventoryAgingResult, + inventoryAsOfDate, + organizationScope, + periodScope: dateScope + }); + if (derivedBusinessOverview) { + pushReason(reasonCodes, "pilot_derived_business_overview_from_confirmed_rows"); + if (derivedBusinessOverview.top_customers.length > 0) { + pushReason(reasonCodes, "pilot_derived_business_overview_top_customers_from_confirmed_rows"); + } + if (derivedBusinessOverview.activity_period) { + pushReason(reasonCodes, "pilot_derived_business_overview_activity_window_from_confirmed_rows"); + } + if (derivedBusinessOverview.tax_position) { + pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows"); + } + if (derivedBusinessOverview.debt_position) { + pushReason(reasonCodes, "pilot_derived_business_overview_debt_position_from_confirmed_rows"); + } + if (derivedBusinessOverview.debt_open_settlement_quality) { + pushReason(reasonCodes, "pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows"); + } + if (derivedBusinessOverview.inventory_position) { + pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows"); + } + } + const sourceRowsSummary = summarizeBusinessOverviewRows({ + incomingResult, + outgoingResult, + lifecycleResult, + taxResult, + receivablesResult, + payablesResult, + openContractsResult, + inventoryOnHandResult, + inventoryAgingResult + }); + const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ + plan: planner.discovery_plan, + probeResults, + confirmedFacts: buildBusinessOverviewConfirmedFacts(derivedBusinessOverview), + inferredFacts: buildBusinessOverviewInferredFacts(derivedBusinessOverview), + unknownFacts: buildBusinessOverviewUnknownFacts(derivedBusinessOverview), + sourceRowsSummary, + queryLimitations, + recommendedNextProbe: "explain_evidence_basis" + }); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "executed", + pilot_scope: "business_overview_route_template_v1", + dry_run: dryRun, + mcp_execution_performed: executedPrimitives.length > 0, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: sourceRowsSummary, + derived_metadata_surface: null, + derived_entity_resolution: null, + derived_activity_period: null, + derived_value_flow: null, + derived_bidirectional_value_flow: null, + derived_business_overview: derivedBusinessOverview, + query_limitations: queryLimitations, + reason_codes: reasonCodes + }; + } if (metadataPilotEligible) { let metadataResult = null; const metadataScope = metadataScopeForPlanner(planner); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index c75e0b2..e3832c7 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -511,6 +511,9 @@ function metadataAmbiguityCollapsesToMovementLane(values) { function hasLifecycleSignal(text) { return /(?:сколько\s+лет|как\s+давно|давно\s+ли|возраст|перв(?:ая|ый)\s+актив|когда\s+начал|когда\s+появ|lifecycle|activity\s+duration|business\s+age|how\s+long)/iu.test(text); } +function hasBusinessOverviewSignal(text) { + return /(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u043e\u0431\u0437\u043e\u0440|\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u0430\u0443\u0434\u0438\u0442|\u043f\u043e\u043b\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u0441\u0432\u043e\u0434\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u043a\u0430\u043a\s+\u0442\u044b\s+\u043e\u0446\u0435\u043d(?:\u0438\u0448\u044c|\u0438)\s+\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u0438|\u0438\u044e|\u0438\u044f)\s+\u0432\s+\u0446\u0435\u043b\u043e\u043c|company\s+(?:analysis|overview)|business\s+(?:overview|audit)|llm[-\s]?audit|бизнес[-\s]?РѕР±Р·РѕСЂ|бизнес[-\s]?аудит)/iu.test(text); +} function hasValueFlowSignal(text) { return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|входящ|получ(?:ил|ено|ен)|поступил|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|(? 0 ? followupSeed.metadataAmbiguityEntitySets : undefined, @@ -1390,29 +1438,32 @@ function buildAssistantMcpDiscoveryTurnInput(input) { explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: explicitDateScope, subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined, - unsupported_but_understood_family: unsupported ?? - (lifecycleSignal - ? "counterparty_lifecycle" - : valueFlowSignal - ? bidirectionalValueFlowSignal - ? "counterparty_bidirectional_value_flow_or_netting" - : payoutSignal - ? "counterparty_payouts_or_outflow" - : seededUnsupported ?? "counterparty_value_or_turnover" - : metadataGroundedMovementLaneApplicable - ? "movement_evidence" - : metadataGroundedDocumentLaneApplicable - ? "document_evidence" - : metadataAmbiguityLaneClarificationApplicable - ? "metadata_lane_choice_clarification" - : entityResolutionSignal - ? "entity_resolution" - : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable - ? "1c_metadata_surface" - : followupDiscoverySeedApplicable - ? seededUnsupported - : null), + unsupported_but_understood_family: businessOverviewSignal + ? "broad_business_evaluation" + : unsupported ?? + (lifecycleSignal + ? "counterparty_lifecycle" + : valueFlowSignal + ? bidirectionalValueFlowSignal + ? "counterparty_bidirectional_value_flow_or_netting" + : payoutSignal + ? "counterparty_payouts_or_outflow" + : seededUnsupported ?? "counterparty_value_or_turnover" + : metadataGroundedMovementLaneApplicable + ? "movement_evidence" + : metadataGroundedDocumentLaneApplicable + ? "document_evidence" + : metadataAmbiguityLaneClarificationApplicable + ? "metadata_lane_choice_clarification" + : entityResolutionSignal + ? "entity_resolution" + : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + ? "1c_metadata_surface" + : followupDiscoverySeedApplicable + ? seededUnsupported + : null), stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || + businessOverviewSignal || unsupported || lifecycleSignal || valueFlowSignal || @@ -1467,7 +1518,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { semanticDataNeed && (entityCandidates.length > 0 || explicitOrganizationScope || openScopeValueFlowWithoutResolvedCounterparty)); const runDiscovery = shouldRunDiscovery({ - unsupported: broadBusinessEvaluationUnsupported ? seededUnsupported : unsupported ?? seededUnsupported, + unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, @@ -1481,7 +1532,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) { metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || groundedValueFlowFollowupApplicable, - forceDiscoveryOverExplicitIntent: Boolean(entityResolutionClarificationCandidate) || + forceDiscoveryOverExplicitIntent: businessOverviewSignal || + Boolean(entityResolutionClarificationCandidate) || organizationClarificationFollowupApplicable || periodClarificationFollowupApplicable || metadataAmbiguityLaneClarificationApplicable || @@ -1554,6 +1606,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (rawAllTimeScopeSignal) { pushReason(reasonCodes, "mcp_discovery_all_time_scope_signal_detected"); } + if (suppressNegatedTaxOnlyDateScope) { + pushReason(reasonCodes, "mcp_discovery_negated_tax_period_scope_suppressed"); + } if (followupDiscoverySeedApplicable) { pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); } @@ -1632,8 +1687,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } - if (broadBusinessEvaluationUnsupported) { - pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_kept_in_living_chat"); + if (businessOverviewSignal) { + pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_route_candidate"); } if (!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) && normalizedPredecomposeCounterparty) { diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index 0633719..6ebb670 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -117,6 +117,11 @@ function detectBroadBusinessEvaluation(text) { if (!normalized) { return null; } + if (/(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u043e\u0431\u0437\u043e\u0440|\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u0430\u0443\u0434\u0438\u0442|\u043f\u043e\u043b\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u0441\u0432\u043e\u0434\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u043a\u0430\u043a\s+\u0442\u044b\s+\u043e\u0446\u0435\u043d(?:\u0438\u0448\u044c|\u0438)\s+\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u0438|\u0438\u044e|\u0438\u044f)\s+\u0432\s+\u0446\u0435\u043b\u043e\u043c|company\s+(?:analysis|overview)|business\s+(?:overview|audit)|llm[-\s]?audit|бизнес[-\s]?РѕР±Р·РѕСЂ|бизнес[-\s]?аудит)/iu.test(normalized)) { + return { + family: "broad_business_evaluation" + }; + } if (/(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|оцени\s+(?:компан|бизнес|деятельност)|(?:полный|сводный|нормальн\w*|взросл\w*)\s+анализ\s+(?:компан|бизнес|деятельност)|проанализируй\s+(?:компан|бизнес|деятельност)|(?:что\s+думаешь|какое\s+мнение)\s+(?:о|по)\s+(?:компан|бизнес)|(?:llm[-\s]?)?аудит\s+(?:компан|бизнес)|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test(normalized)) { return { family: "broad_business_evaluation" @@ -214,7 +219,7 @@ function createAssistantTurnMeaningPolicy(deps = {}) { asked_domain_family: askedDomainFamily, asked_action_family: askedActionFamily, explicit_intent_candidate: explicitIntentCandidate, - explicit_entity_candidates: buildEntityCandidates(counterpartyTurnover), + explicit_entity_candidates: broadBusinessEvaluation?.family ? [] : buildEntityCandidates(counterpartyTurnover), meaning_confidence: broadBusinessEvaluation?.family ? "medium" : supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 40db490..3feed56 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -158,6 +158,10 @@ function isValueFlowPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): b ); } +function isBusinessOverviewPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { + return pilot.pilot_scope === "business_overview_route_template_v1"; +} + function isDocumentPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { return pilot.pilot_scope === "counterparty_document_evidence_query_documents_v1"; } @@ -431,6 +435,44 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD } return "Инвентарный route-template уже выбран, но live-исполнение этого generic MCP контура еще не подключено; складской/товарный факт не подтвержден."; } + if (isBusinessOverviewPilot(pilot) && pilot.derived_business_overview && mode === "confirmed_with_bounded_inference") { + const overview = pilot.derived_business_overview; + const families: string[] = []; + if ( + overview.incoming_customer_revenue.rows_with_amount > 0 || + overview.outgoing_supplier_payout.rows_with_amount > 0 + ) { + families.push("денежный поток"); + } + if (overview.activity_period) { + families.push("активность"); + } + if (overview.tax_position) { + families.push("НДС-позиция"); + } + if (overview.debt_position) { + families.push("долговой срез на дату"); + } + if (overview.debt_open_settlement_quality) { + families.push("качество открытых расчетов"); + } + if (overview.inventory_position) { + families.push("складской срез на дату"); + } + const unknownFamilies = ["прибыль/маржа"]; + if (!overview.tax_position) { + unknownFamilies.push("НДС"); + } + if (!overview.debt_position) { + unknownFamilies.push("долговой срез"); + } + unknownFamilies.push(overview.debt_open_settlement_quality ? "due-date просрочка" : "качество открытых расчетов"); + unknownFamilies.push(overview.inventory_position ? "полноценная складская ликвидность" : "склад"); + return `По данным 1С собран ограниченный бизнес-обзор: ${families.join(", ")} подтверждены найденными строками; ${unknownFamilies.join(", ")} остаются отдельными непроверенными областями.`; + } + if (isBusinessOverviewPilot(pilot) && mode === "checked_sources_only") { + return "Бизнес-обзор был запущен, но подтвержденные денежные или activity-сигналы в найденных строках не получены."; + } if (isEntityResolutionPilot(pilot) && mode === "needs_clarification") { return "По каталогу 1С нашлось несколько похожих контрагентов, и без уточнения нельзя честно выбрать правильную сущность."; } @@ -574,6 +616,9 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD } return "Следующий шаг - связать inventory route-template с exact inventory runtime и затем проверить live-прогоном."; } + if (mode === "confirmed_with_bounded_inference" && isBusinessOverviewPilot(pilot)) { + return "Если нужен уже управленческий вывод, следующим шагом стоит отдельно проверить прибыль/маржу, долги, НДС и складскую ликвидность, а затем собрать полный бизнес-аудит."; + } if (mode === "confirmed_with_bounded_inference" && pilot.derived_metadata_surface) { const surface = pilot.derived_metadata_surface; if (surface.ambiguity_detected && surface.ambiguity_entity_sets.length > 0) { @@ -606,6 +651,14 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): claims.push("Do not claim full all-time turnover unless the checked period and coverage prove it."); claims.push("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows."); } + if (isBusinessOverviewPilot(pilot)) { + claims.push("Do not present business overview cash-flow spread as profit or margin."); + claims.push("Do not claim debt quality, VAT position, inventory health, or company health unless those contours were separately checked."); + claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis."); + claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt."); + claims.push("Do not present an inventory snapshot or purchase-date aging signal as turnover, obsolescence, liquidation value, or full inventory health."); + claims.push("Do not expose business_overview_route_template_v1 or MCP primitive names in the user answer."); + } if (pilot.derived_ranked_value_flow) { claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization."); claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist."); @@ -899,6 +952,119 @@ function derivedBidirectionalValueFlowMonthlyLines(pilot: AssistantMcpDiscoveryP ); } +function businessOverviewNetDirectionRu(direction: "net_incoming" | "net_outgoing" | "balanced"): string { + if (direction === "net_incoming") { + return "операционный денежный поток в проверенном срезе больше входящий, чем исходящий"; + } + if (direction === "net_outgoing") { + return "операционный денежный поток в проверенном срезе больше исходящий, чем входящий"; + } + return "входящий и исходящий денежный поток в проверенном срезе примерно сбалансированы"; +} + +function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] { + const overview = pilot.derived_business_overview; + if (!overview) { + return []; + } + const organization = overview.organization_scope ? ` по организации ${overview.organization_scope}` : ""; + const period = overview.period_scope ? ` за ${overview.period_scope}` : " за все доступное проверенное окно"; + const lines: string[] = []; + if (overview.incoming_customer_revenue.rows_with_amount > 0) { + lines.push( + `Входящие поступления${organization}${period}: ${overview.incoming_customer_revenue.total_amount_human_ru} по ${overview.incoming_customer_revenue.rows_with_amount} строкам с суммой.` + ); + } + if (overview.outgoing_supplier_payout.rows_with_amount > 0) { + lines.push( + `Исходящие платежи/списания${organization}${period}: ${overview.outgoing_supplier_payout.total_amount_human_ru} по ${overview.outgoing_supplier_payout.rows_with_amount} строкам с суммой.` + ); + } + const leader = overview.top_customers[0]; + if (leader) { + lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + } + if (overview.activity_period) { + lines.push( + `Окно подтвержденной активности в 1С: ${overview.activity_period.first_activity_date} — ${overview.activity_period.latest_activity_date}; ориентировочно ${overview.activity_period.duration_human_ru}.` + ); + } + if (overview.tax_position) { + const taxDirection = + overview.tax_position.net_vat_direction === "vat_to_pay" + ? "к уплате" + : overview.tax_position.net_vat_direction === "vat_to_recover_or_offset" + ? "к вычету/зачету" + : "сбалансирован"; + lines.push( + `НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.` + ); + } + if (overview.debt_position) { + const debtDirection = + overview.debt_position.net_debt_position_direction === "net_receivable" + ? "в пользу дебиторки" + : overview.debt_position.net_debt_position_direction === "net_payable" + ? "в сторону кредиторки" + : "сбалансировано"; + lines.push( + `Долговой срез на ${overview.debt_position.as_of_date}: дебиторка ${overview.debt_position.receivables.total_amount_human_ru}, кредиторка ${overview.debt_position.payables.total_amount_human_ru}, нетто ${debtDirection} ${overview.debt_position.net_debt_position_amount_human_ru}.` + ); + } + if (overview.debt_open_settlement_quality) { + const quality = overview.debt_open_settlement_quality; + const topContract = quality.top_contracts[0]; + const topContractText = topContract + ? ` Крупнейший открытый договор: ${topContract.contract}${topContract.counterparty ? ` / ${topContract.counterparty}` : ""} — ${topContract.total_amount_human_ru}${topContract.share_of_gross_open_amount_pct === null ? "" : ` (${topContract.share_of_gross_open_amount_pct}%)`}.` + : ""; + lines.push( + `Качество открытых расчетов на ${quality.as_of_date}: брутто открытых договорных остатков ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${topContractText}` + ); + } + if (overview.inventory_position) { + const leader = overview.inventory_position.top_items[0]; + const leaderText = leader + ? ` Крупнейшая подтвержденная позиция: ${leader.item} — ${leader.total_amount_human_ru}.` + : ""; + lines.push( + `Складской срез на ${overview.inventory_position.as_of_date}: остаток ${overview.inventory_position.total_amount_human_ru} по ${overview.inventory_position.rows_with_amount} строкам с суммой и ${overview.inventory_position.rows_with_quantity} строкам с количеством.${leaderText}` + ); + if (overview.inventory_position.aging_signal?.oldest_purchase_date) { + const ageText = overview.inventory_position.aging_signal.max_age_days === null + ? "" + : `, максимальный возраст сигнала ${overview.inventory_position.aging_signal.max_age_days} дн.`; + lines.push( + `Возрастной сигнал склада: самая ранняя найденная дата закупки ${overview.inventory_position.aging_signal.oldest_purchase_date}${ageText}.` + ); + } + } + return lines; +} + +function derivedBusinessOverviewInferenceLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + const overview = pilot.derived_business_overview; + if (!overview) { + return null; + } + if ( + overview.incoming_customer_revenue.rows_with_amount <= 0 && + overview.outgoing_supplier_payout.rows_with_amount <= 0 + ) { + return null; + } + return [ + `Расчетное нетто по найденным строкам: ${overview.net_amount_human_ru}; ${businessOverviewNetDirectionRu(overview.net_direction)}.`, + "Это нормальный операционный сигнал, но не прибыль и не маржа: для управленческого вывода нужны отдельные расходы, себестоимость, долги, налоги и склад." + ].join(" "); +} + +function businessOverviewUnknownLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] { + if (!pilot.derived_business_overview) { + return userFacingUnknowns(pilot.evidence.unknown_facts); + } + return userFacingUnknowns(pilot.evidence.unknown_facts); +} + export function buildAssistantMcpDiscoveryAnswerDraft( pilot: AssistantMcpDiscoveryPilotExecutionContract ): AssistantMcpDiscoveryAnswerDraftContract { @@ -912,6 +1078,7 @@ export function buildAssistantMcpDiscoveryAnswerDraft( pushReason(reasonCodes, "answer_contains_bounded_inference"); } const derivedInferenceLine = + derivedBusinessOverviewInferenceLine(pilot) ?? derivedActivityInferenceLine(pilot) ?? derivedMetadataInferenceLine(pilot) ?? derivedRankedValueFlowInferenceLine(pilot) ?? @@ -929,10 +1096,28 @@ export function buildAssistantMcpDiscoveryAnswerDraft( derivedBidirectionalValueFlowMonthlyLines(pilot).length > 0 ? derivedBidirectionalValueFlowMonthlyLines(pilot) : derivedValueFlowMonthlyLines(pilot); + const businessOverviewLines = derivedBusinessOverviewConfirmedLines(pilot); if (monthlyConfirmedLines.length > 0) { pushReason(reasonCodes, "answer_contains_monthly_breakdown"); } - const confirmedLines = pilot.derived_ranked_value_flow && derivedValueLine + if (businessOverviewLines.length > 0) { + pushReason(reasonCodes, "answer_contains_business_overview"); + } + if (pilot.derived_business_overview?.tax_position) { + pushReason(reasonCodes, "answer_contains_business_overview_tax_position"); + } + if (pilot.derived_business_overview?.debt_position) { + pushReason(reasonCodes, "answer_contains_business_overview_debt_position"); + } + if (pilot.derived_business_overview?.debt_open_settlement_quality) { + pushReason(reasonCodes, "answer_contains_business_overview_open_settlement_quality"); + } + if (pilot.derived_business_overview?.inventory_position) { + pushReason(reasonCodes, "answer_contains_business_overview_inventory_position"); + } + const confirmedLines = businessOverviewLines.length > 0 + ? businessOverviewLines + : pilot.derived_ranked_value_flow && derivedValueLine ? [derivedValueLine] : derivedValueLine ? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines] @@ -941,7 +1126,9 @@ export function buildAssistantMcpDiscoveryAnswerDraft( : derivedMetadataLine ? [derivedMetadataLine] : pilot.evidence.confirmed_facts; - const unknownLines = pilot.derived_metadata_surface + const unknownLines = pilot.derived_business_overview + ? businessOverviewUnknownLines(pilot) + : pilot.derived_metadata_surface ? pilot.derived_metadata_surface.available_fields.length > 0 ? userFacingUnknowns(pilot.evidence.unknown_facts) : ["Детальный список полей этих объектов этим шагом не получен."] diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index fac9d78..3d4ee71 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -141,6 +141,120 @@ export interface AssistantMcpDiscoveryDerivedBidirectionalValueFlow { inference_basis: "incoming_minus_outgoing_confirmed_1c_value_flow_rows"; } +export interface AssistantMcpDiscoveryDerivedBusinessOverview { + organization_scope: string | null; + period_scope: string | null; + incoming_customer_revenue: AssistantMcpDiscoveryValueFlowSideSummary; + outgoing_supplier_payout: AssistantMcpDiscoveryValueFlowSideSummary; + net_amount: number; + net_amount_human_ru: string; + net_direction: AssistantMcpDiscoveryNetDirection; + top_customers: AssistantMcpDiscoveryRankedValueFlowBucket[]; + activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null; + tax_position: AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition | null; + debt_position: AssistantMcpDiscoveryDerivedBusinessOverviewDebtPosition | null; + debt_open_settlement_quality: AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality | null; + inventory_position: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null; + coverage_limited_by_probe_limit: boolean; + checked_signal_count: number; + missing_signal_families: string[]; + inference_basis: + | "business_overview_from_confirmed_1c_money_and_activity_rows" + | "business_overview_from_confirmed_1c_money_activity_and_tax_rows" + | "business_overview_from_confirmed_1c_money_activity_and_debt_rows" + | "business_overview_from_confirmed_1c_money_activity_tax_and_debt_rows" + | "business_overview_from_confirmed_1c_multi_family_rows"; +} + +export interface AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition { + period_scope: string; + rows_matched: number; + rows_with_amount: number; + sales_vat_amount: number; + sales_vat_amount_human_ru: string; + purchase_vat_amount: number; + purchase_vat_amount_human_ru: string; + net_vat_amount: number; + net_vat_amount_human_ru: string; + net_vat_direction: "vat_to_pay" | "vat_to_recover_or_offset" | "balanced"; + inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows"; +} + +export interface AssistantMcpDiscoveryBusinessOverviewDebtSideSummary { + rows_matched: number; + rows_with_amount: number; + total_amount: number; + total_amount_human_ru: string; + top_counterparties: AssistantMcpDiscoveryRankedValueFlowBucket[]; +} + +export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtPosition { + as_of_date: string; + receivables: AssistantMcpDiscoveryBusinessOverviewDebtSideSummary; + payables: AssistantMcpDiscoveryBusinessOverviewDebtSideSummary; + net_debt_position_amount: number; + net_debt_position_amount_human_ru: string; + net_debt_position_direction: "net_receivable" | "net_payable" | "balanced"; + inference_basis: "receivables_minus_payables_confirmed_1c_balance_rows"; +} + +export interface AssistantMcpDiscoveryBusinessOverviewDebtOpenContractBucket { + contract: string; + counterparty: string | null; + rows_with_amount: number; + total_amount: number; + total_amount_human_ru: string; + share_of_gross_open_amount_pct: number | null; +} + +export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality { + as_of_date: string; + rows_matched: number; + rows_with_amount: number; + gross_open_amount: number; + gross_open_amount_human_ru: string; + unique_counterparties: number; + unique_contracts: number; + rows_without_counterparty: number; + rows_without_contract: number; + top_counterparties: AssistantMcpDiscoveryRankedValueFlowBucket[]; + top_contracts: AssistantMcpDiscoveryBusinessOverviewDebtOpenContractBucket[]; + concentration_top_counterparty_pct: number | null; + concentration_top_contract_pct: number | null; + inference_basis: "open_contracts_confirmed_1c_balance_rows"; +} + +export interface AssistantMcpDiscoveryBusinessOverviewInventoryItemBucket { + item: string; + rows_with_amount: number; + rows_with_quantity: number; + total_amount: number; + total_amount_human_ru: string; + total_quantity: number; +} + +export interface AssistantMcpDiscoveryBusinessOverviewInventoryAgingSignal { + rows_matched: number; + rows_with_purchase_date: number; + oldest_purchase_date: string | null; + latest_purchase_date: string | null; + max_age_days: number | null; + inference_basis: "inventory_purchase_dates_from_confirmed_1c_rows"; +} + +export interface AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition { + as_of_date: string; + rows_matched: number; + rows_with_amount: number; + rows_with_quantity: number; + total_amount: number; + total_amount_human_ru: string; + total_quantity: number; + top_items: AssistantMcpDiscoveryBusinessOverviewInventoryItemBucket[]; + aging_signal: AssistantMcpDiscoveryBusinessOverviewInventoryAgingSignal | null; + inference_basis: "inventory_on_hand_confirmed_1c_balance_rows"; +} + export interface AssistantMcpDiscoveryDerivedMetadataSurface { metadata_scope: string | null; requested_meta_types: string[]; @@ -221,6 +335,7 @@ export interface AssistantMcpDiscoveryPilotExecutionContract { derived_ranked_value_flow?: AssistantMcpDiscoveryDerivedRankedValueFlow | null; derived_value_flow: AssistantMcpDiscoveryDerivedValueFlow | null; derived_bidirectional_value_flow: AssistantMcpDiscoveryDerivedBidirectionalValueFlow | null; + derived_business_overview?: AssistantMcpDiscoveryDerivedBusinessOverview | null; query_limitations: string[]; reason_codes: string[]; } @@ -394,6 +509,54 @@ function buildValueFlowFilters(planner: AssistantMcpDiscoveryPlannerContract): A }; } +function buildBusinessOverviewTaxFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet | null { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const periodFilters = dateScopeToFilters(dateScope); + if (!periodFilters.period_from || !periodFilters.period_to) { + return null; + } + return { + ...periodFilters, + ...(organization ? { organization } : {}) + }; +} + +function buildBusinessOverviewDebtFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet | null { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const asOfDate = asOfDateFromDateScope(dateScope); + if (!asOfDate) { + return null; + } + return { + ...dateScopeToFilters(dateScope), + as_of_date: asOfDate, + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} + +function buildBusinessOverviewInventoryFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet | null { + const meaning = planner.discovery_plan.turn_meaning_ref; + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + const asOfDate = asOfDateFromDateScope(dateScope); + if (!asOfDate) { + return null; + } + return { + ...dateScopeToFilters(dateScope), + as_of_date: asOfDate, + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} + function buildInventoryExactFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet { const meaning = planner.discovery_plan.turn_meaning_ref; const subject = firstEntityCandidate(planner); @@ -671,6 +834,10 @@ function isValueFlowPilotEligible(planner: AssistantMcpDiscoveryPlannerContract) ); } +function isBusinessOverviewPilotEligible(planner: AssistantMcpDiscoveryPlannerContract): boolean { + return planner.selected_chain_id === "business_overview"; +} + function isInventoryPilotEligible(planner: AssistantMcpDiscoveryPlannerContract): boolean { return ( planner.selected_chain_id === "inventory_stock_snapshot" || @@ -1895,10 +2062,93 @@ function rowDocumentValue(row: Record): string | null { return rowTextValue(row, ["Регистратор", "Registrator", "registrator", "Документ", "Document", "document"]); } +function rowAccountValue(row: Record): string | null { + return rowTextValue(row, ["СчетДт", "AccountDt", "account_dt", "Счет", "Account", "account"]); +} + function rowQuantityValue(row: Record): number | null { return rowNumberValue(row, ["Количество", "Quantity", "quantity", "Qty", "qty", "Остаток", "Balance", "balance"]); } +function rowAnalyticsTextValues(row: Record): string[] { + const values: string[] = []; + const analytics = row["analytics"]; + if (Array.isArray(analytics)) { + for (const item of analytics) { + const text = toNonEmptyString(item); + if (text && !values.includes(text)) { + values.push(text); + } + } + } + for (const key of [ + "СубконтоДт1", + "СубконтоДт2", + "СубконтоДт3", + "СубконтоКт1", + "СубконтоКт2", + "СубконтоКт3", + "SubcontoDt1", + "SubcontoDt2", + "SubcontoDt3", + "SubcontoKt1", + "SubcontoKt2", + "SubcontoKt3" + ]) { + const text = toNonEmptyString(row[key]); + if (text && !values.includes(text)) { + values.push(text); + } + } + return values; +} + +function isEmptyAnalyticToken(value: string): boolean { + return /^(?:0|<пусто>|пустая ссылка)$/iu.test(value.trim()); +} + +function isLikelyContractToken(value: string): boolean { + const normalized = value.trim(); + if (!normalized || isEmptyAnalyticToken(normalized)) { + return false; + } + if (/(?:договор|contract|дог\.)/iu.test(normalized)) { + return true; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + return false; + } + return normalized.length >= 3 && /[\\/]/.test(normalized); +} + +function isLikelyCounterpartyToken(value: string): boolean { + const normalized = value.trim(); + if (!normalized || isEmptyAnalyticToken(normalized)) { + return false; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + return false; + } + if (/^\d+(?:[./-]\d+)*$/.test(normalized)) { + return false; + } + if (!/[a-zа-я]/iu.test(normalized)) { + return false; + } + if (/(?:договор|contract|дог\.|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|банк|касса|movement|invoice|payment)/iu.test(normalized)) { + return false; + } + return true; +} + +function rowContractValue(row: Record): string | null { + const explicit = rowTextValue(row, ["Договор", "Contract", "contract"]); + if (explicit && !isEmptyAnalyticToken(explicit)) { + return explicit; + } + return rowAnalyticsTextValues(row).find(isLikelyContractToken) ?? null; +} + function rowCounterpartyValue(row: Record): string | null { const candidates = [row["Контрагент"], row["Counterparty"], row["counterparty"], row["Наименование"], row["name"]]; for (const candidate of candidates) { @@ -1907,7 +2157,7 @@ function rowCounterpartyValue(row: Record): string | null { return text; } } - return null; + return rowAnalyticsTextValues(row).find(isLikelyCounterpartyToken) ?? null; } function monthBucketFromIsoDate(isoDate: string | null): string | null { @@ -2280,6 +2530,682 @@ function summarizeBidirectionalValueFlowRows(input: { return `${incomingSummary}; ${outgoingSummary}`; } +function deriveBusinessOverviewTaxPosition( + result: AddressMcpQueryExecutorResult | null, + periodScope: string | null +): AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition | null { + if (!result || result.error || result.matched_rows <= 0 || !periodScope) { + return null; + } + + let salesVatAmount = 0; + let purchaseVatAmount = 0; + let rowsWithAmount = 0; + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + const marker = String(rowDocumentValue(row) ?? "").toLowerCase(); + const account = String(rowAccountValue(row) ?? "").toLowerCase(); + if (marker.includes("sales") || marker.includes("продаж") || account.startsWith("68")) { + salesVatAmount += amount; + rowsWithAmount += 1; + continue; + } + if (marker.includes("purchase") || marker.includes("покуп") || account.startsWith("19")) { + purchaseVatAmount += amount; + rowsWithAmount += 1; + } + } + + if (rowsWithAmount <= 0) { + return null; + } + + const netVatAmount = salesVatAmount - purchaseVatAmount; + const netVatDirection = + netVatAmount > 0 + ? "vat_to_pay" + : netVatAmount < 0 + ? "vat_to_recover_or_offset" + : "balanced"; + + return { + period_scope: periodScope, + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + sales_vat_amount: salesVatAmount, + sales_vat_amount_human_ru: formatAmountHumanRu(salesVatAmount), + purchase_vat_amount: purchaseVatAmount, + purchase_vat_amount_human_ru: formatAmountHumanRu(purchaseVatAmount), + net_vat_amount: netVatAmount, + net_vat_amount_human_ru: formatAmountHumanRu(Math.abs(netVatAmount)), + net_vat_direction: netVatDirection, + inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows" + }; +} + +function deriveBusinessOverviewDebtSide( + result: AddressMcpQueryExecutorResult | null +): AssistantMcpDiscoveryBusinessOverviewDebtSideSummary { + if (!result || result.error || result.matched_rows <= 0) { + return { + rows_matched: 0, + rows_with_amount: 0, + total_amount: 0, + total_amount_human_ru: formatAmountHumanRu(0), + top_counterparties: [] + }; + } + + const buckets = new Map(); + let rowsWithAmount = 0; + let totalAmount = 0; + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + rowsWithAmount += 1; + totalAmount += amount; + const counterparty = rowCounterpartyValue(row) ?? "unknown_counterparty"; + const current = buckets.get(counterparty) ?? { rows_with_amount: 0, total_amount: 0 }; + current.rows_with_amount += 1; + current.total_amount += amount; + buckets.set(counterparty, current); + } + + const topCounterparties = Array.from(buckets.entries()) + .map(([axisValue, bucket]) => ({ + axis_value: axisValue, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + return amountDelta !== 0 ? amountDelta : left.axis_value.localeCompare(right.axis_value, "ru"); + }) + .slice(0, 5); + + return { + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + total_amount: totalAmount, + total_amount_human_ru: formatAmountHumanRu(totalAmount), + top_counterparties: topCounterparties + }; +} + +function deriveBusinessOverviewDebtPosition(input: { + receivablesResult: AddressMcpQueryExecutorResult | null; + payablesResult: AddressMcpQueryExecutorResult | null; + debtAsOfDate: string | null; +}): AssistantMcpDiscoveryDerivedBusinessOverviewDebtPosition | null { + if (!input.debtAsOfDate) { + return null; + } + const receivables = deriveBusinessOverviewDebtSide(input.receivablesResult); + const payables = deriveBusinessOverviewDebtSide(input.payablesResult); + if (receivables.rows_with_amount <= 0 && payables.rows_with_amount <= 0) { + return null; + } + const netDebtPositionAmount = receivables.total_amount - payables.total_amount; + const netDebtPositionDirection = + netDebtPositionAmount > 0 + ? "net_receivable" + : netDebtPositionAmount < 0 + ? "net_payable" + : "balanced"; + + return { + as_of_date: input.debtAsOfDate, + receivables, + payables, + net_debt_position_amount: netDebtPositionAmount, + net_debt_position_amount_human_ru: formatAmountHumanRu(Math.abs(netDebtPositionAmount)), + net_debt_position_direction: netDebtPositionDirection, + inference_basis: "receivables_minus_payables_confirmed_1c_balance_rows" + }; +} + +function percentageOfTotal(part: number, total: number): number | null { + if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) { + return null; + } + return Math.round((part / total) * 10_000) / 100; +} + +function deriveBusinessOverviewDebtOpenSettlementQuality(input: { + openContractsResult: AddressMcpQueryExecutorResult | null; + debtAsOfDate: string | null; +}): AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality | null { + if (!input.debtAsOfDate || !input.openContractsResult || input.openContractsResult.error || input.openContractsResult.matched_rows <= 0) { + return null; + } + + const counterpartyBuckets = new Map(); + const contractBuckets = new Map< + string, + { counterparty: string | null; rows_with_amount: number; total_amount: number } + >(); + const counterparties = new Set(); + const contracts = new Set(); + let rowsWithAmount = 0; + let grossOpenAmount = 0; + let rowsWithoutCounterparty = 0; + let rowsWithoutContract = 0; + + for (const row of input.openContractsResult.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + const absAmount = Math.abs(amount); + if (absAmount <= 0) { + continue; + } + rowsWithAmount += 1; + grossOpenAmount += absAmount; + + const counterparty = rowCounterpartyValue(row); + const contract = rowContractValue(row); + if (counterparty) { + counterparties.add(counterparty); + const current = counterpartyBuckets.get(counterparty) ?? { rows_with_amount: 0, total_amount: 0 }; + current.rows_with_amount += 1; + current.total_amount += absAmount; + counterpartyBuckets.set(counterparty, current); + } else { + rowsWithoutCounterparty += 1; + } + + if (contract) { + contracts.add(contract); + const current = contractBuckets.get(contract) ?? { + counterparty, + rows_with_amount: 0, + total_amount: 0 + }; + if (!current.counterparty && counterparty) { + current.counterparty = counterparty; + } + current.rows_with_amount += 1; + current.total_amount += absAmount; + contractBuckets.set(contract, current); + } else { + rowsWithoutContract += 1; + } + } + + if (rowsWithAmount <= 0) { + return null; + } + + const topCounterparties = Array.from(counterpartyBuckets.entries()) + .map(([axisValue, bucket]) => ({ + axis_value: axisValue, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + return amountDelta !== 0 ? amountDelta : left.axis_value.localeCompare(right.axis_value, "ru"); + }) + .slice(0, 5); + + const topContracts = Array.from(contractBuckets.entries()) + .map(([contract, bucket]) => ({ + contract, + counterparty: bucket.counterparty, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount), + share_of_gross_open_amount_pct: percentageOfTotal(bucket.total_amount, grossOpenAmount) + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + return amountDelta !== 0 ? amountDelta : left.contract.localeCompare(right.contract, "ru"); + }) + .slice(0, 5); + + return { + as_of_date: input.debtAsOfDate, + rows_matched: input.openContractsResult.matched_rows, + rows_with_amount: rowsWithAmount, + gross_open_amount: grossOpenAmount, + gross_open_amount_human_ru: formatAmountHumanRu(grossOpenAmount), + unique_counterparties: counterparties.size, + unique_contracts: contracts.size, + rows_without_counterparty: rowsWithoutCounterparty, + rows_without_contract: rowsWithoutContract, + top_counterparties: topCounterparties, + top_contracts: topContracts, + concentration_top_counterparty_pct: percentageOfTotal(topCounterparties[0]?.total_amount ?? 0, grossOpenAmount), + concentration_top_contract_pct: percentageOfTotal(topContracts[0]?.total_amount ?? 0, grossOpenAmount), + inference_basis: "open_contracts_confirmed_1c_balance_rows" + }; +} + +function daysBetweenIsoDates(leftIsoDate: string, rightIsoDate: string): number | null { + const leftMatch = leftIsoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + const rightMatch = rightIsoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!leftMatch || !rightMatch) { + return null; + } + const left = Date.UTC(Number(leftMatch[1]), Number(leftMatch[2]) - 1, Number(leftMatch[3])); + const right = Date.UTC(Number(rightMatch[1]), Number(rightMatch[2]) - 1, Number(rightMatch[3])); + if (!Number.isFinite(left) || !Number.isFinite(right)) { + return null; + } + return Math.max(0, Math.floor((right - left) / 86_400_000)); +} + +function deriveBusinessOverviewInventoryAgingSignal( + result: AddressMcpQueryExecutorResult | null, + inventoryAsOfDate: string +): AssistantMcpDiscoveryBusinessOverviewInventoryAgingSignal | null { + if (!result || result.error || result.matched_rows <= 0) { + return null; + } + + const dates = result.rows + .map((row) => rowDateValue(row)) + .filter((value): value is string => Boolean(value)) + .sort((left, right) => left.localeCompare(right)); + if (dates.length <= 0) { + return null; + } + + const oldestPurchaseDate = dates[0] ?? null; + const latestPurchaseDate = dates[dates.length - 1] ?? null; + return { + rows_matched: result.matched_rows, + rows_with_purchase_date: dates.length, + oldest_purchase_date: oldestPurchaseDate, + latest_purchase_date: latestPurchaseDate, + max_age_days: oldestPurchaseDate ? daysBetweenIsoDates(oldestPurchaseDate, inventoryAsOfDate) : null, + inference_basis: "inventory_purchase_dates_from_confirmed_1c_rows" + }; +} + +function deriveBusinessOverviewInventoryPosition(input: { + inventoryOnHandResult: AddressMcpQueryExecutorResult | null; + inventoryAgingResult: AddressMcpQueryExecutorResult | null; + inventoryAsOfDate: string | null; +}): AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null { + const { inventoryAsOfDate, inventoryOnHandResult } = input; + if (!inventoryAsOfDate || !inventoryOnHandResult || inventoryOnHandResult.error || inventoryOnHandResult.matched_rows <= 0) { + return null; + } + + const buckets = new Map(); + let rowsWithAmount = 0; + let rowsWithQuantity = 0; + let totalAmount = 0; + let totalQuantity = 0; + + for (const row of inventoryOnHandResult.rows) { + const amount = rowAmountValue(row); + const quantity = rowQuantityValue(row); + if (amount === null && quantity === null) { + continue; + } + const item = rowInventoryItemValue(row) ?? "unknown_item"; + const current = buckets.get(item) ?? { + rows_with_amount: 0, + rows_with_quantity: 0, + total_amount: 0, + total_quantity: 0 + }; + if (amount !== null) { + rowsWithAmount += 1; + totalAmount += amount; + current.rows_with_amount += 1; + current.total_amount += amount; + } + if (quantity !== null) { + rowsWithQuantity += 1; + totalQuantity += quantity; + current.rows_with_quantity += 1; + current.total_quantity += quantity; + } + buckets.set(item, current); + } + + if (rowsWithAmount <= 0 && rowsWithQuantity <= 0) { + return null; + } + + const topItems = Array.from(buckets.entries()) + .map(([item, bucket]) => ({ + item, + rows_with_amount: bucket.rows_with_amount, + rows_with_quantity: bucket.rows_with_quantity, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount), + total_quantity: bucket.total_quantity + })) + .sort((left, right) => { + const amountDelta = right.total_amount - left.total_amount; + if (amountDelta !== 0) { + return amountDelta; + } + const quantityDelta = right.total_quantity - left.total_quantity; + return quantityDelta !== 0 ? quantityDelta : left.item.localeCompare(right.item, "ru"); + }) + .slice(0, 5); + + return { + as_of_date: inventoryAsOfDate, + rows_matched: inventoryOnHandResult.matched_rows, + rows_with_amount: rowsWithAmount, + rows_with_quantity: rowsWithQuantity, + total_amount: totalAmount, + total_amount_human_ru: formatAmountHumanRu(totalAmount), + total_quantity: totalQuantity, + top_items: topItems, + aging_signal: deriveBusinessOverviewInventoryAgingSignal(input.inventoryAgingResult, inventoryAsOfDate), + inference_basis: "inventory_on_hand_confirmed_1c_balance_rows" + }; +} + +function deriveBusinessOverview(input: { + incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + lifecycleResult: AddressMcpQueryExecutorResult | null; + taxResult: AddressMcpQueryExecutorResult | null; + receivablesResult: AddressMcpQueryExecutorResult | null; + payablesResult: AddressMcpQueryExecutorResult | null; + openContractsResult: AddressMcpQueryExecutorResult | null; + debtAsOfDate: string | null; + inventoryOnHandResult: AddressMcpQueryExecutorResult | null; + inventoryAgingResult: AddressMcpQueryExecutorResult | null; + inventoryAsOfDate: string | null; + organizationScope: string | null; + periodScope: string | null; +}): AssistantMcpDiscoveryDerivedBusinessOverview | null { + const incoming = deriveValueFlowSideSummary(input.incomingResult); + const outgoing = deriveValueFlowSideSummary(input.outgoingResult); + const rankedIncoming = deriveRankedValueFlow(input.incomingResult, { + organizationScope: input.organizationScope, + periodScope: input.periodScope, + direction: "incoming_customer_revenue", + rankingNeed: "top_desc" + }); + const activityPeriod = deriveActivityPeriod(input.lifecycleResult); + const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope); + const debtPosition = deriveBusinessOverviewDebtPosition({ + receivablesResult: input.receivablesResult, + payablesResult: input.payablesResult, + debtAsOfDate: input.debtAsOfDate + }); + const debtOpenSettlementQuality = deriveBusinessOverviewDebtOpenSettlementQuality({ + openContractsResult: input.openContractsResult, + debtAsOfDate: input.debtAsOfDate + }); + const inventoryPosition = deriveBusinessOverviewInventoryPosition({ + inventoryOnHandResult: input.inventoryOnHandResult, + inventoryAgingResult: input.inventoryAgingResult, + inventoryAsOfDate: input.inventoryAsOfDate + }); + const checkedSignalCount = [ + incoming.rows_with_amount > 0, + outgoing.rows_with_amount > 0, + Boolean(activityPeriod), + Boolean(taxPosition), + Boolean(debtPosition), + Boolean(debtOpenSettlementQuality), + Boolean(inventoryPosition) + ].filter(Boolean).length; + if (checkedSignalCount <= 0) { + return null; + } + + const netAmount = incoming.total_amount - outgoing.total_amount; + return { + organization_scope: input.organizationScope, + period_scope: input.periodScope, + incoming_customer_revenue: incoming, + outgoing_supplier_payout: outgoing, + net_amount: netAmount, + net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)), + net_direction: netDirectionFromAmount(netAmount), + top_customers: rankedIncoming?.ranked_values ?? [], + activity_period: activityPeriod, + tax_position: taxPosition, + debt_position: debtPosition, + debt_open_settlement_quality: debtOpenSettlementQuality, + inventory_position: inventoryPosition, + coverage_limited_by_probe_limit: + incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, + checked_signal_count: checkedSignalCount, + missing_signal_families: [ + "profit_margin", + debtPosition ? null : "debt_position", + debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", + taxPosition ? null : "tax_position", + inventoryPosition ? "inventory_turnover_quality" : "inventory_position", + inventoryPosition?.aging_signal ? null : "inventory_aging_quality" + ].filter((item): item is string => Boolean(item)), + inference_basis: + inventoryPosition + ? "business_overview_from_confirmed_1c_multi_family_rows" + : debtOpenSettlementQuality + ? "business_overview_from_confirmed_1c_multi_family_rows" + : taxPosition && debtPosition + ? "business_overview_from_confirmed_1c_money_activity_tax_and_debt_rows" + : taxPosition + ? "business_overview_from_confirmed_1c_money_activity_and_tax_rows" + : debtPosition + ? "business_overview_from_confirmed_1c_money_activity_and_debt_rows" + : "business_overview_from_confirmed_1c_money_and_activity_rows" + }; +} + +function summarizeBusinessOverviewRows(input: { + incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + lifecycleResult: AddressMcpQueryExecutorResult | null; + taxResult: AddressMcpQueryExecutorResult | null; + receivablesResult: AddressMcpQueryExecutorResult | null; + payablesResult: AddressMcpQueryExecutorResult | null; + openContractsResult: AddressMcpQueryExecutorResult | null; + inventoryOnHandResult: AddressMcpQueryExecutorResult | null; + inventoryAgingResult: AddressMcpQueryExecutorResult | null; +}): string | null { + const parts: string[] = []; + if (input.incomingResult && !input.incomingResult.error) { + parts.push(`${input.incomingResult.fetched_rows} incoming rows fetched, ${input.incomingResult.matched_rows} matched`); + } + if (input.outgoingResult && !input.outgoingResult.error) { + parts.push(`${input.outgoingResult.fetched_rows} outgoing rows fetched, ${input.outgoingResult.matched_rows} matched`); + } + if (input.lifecycleResult && !input.lifecycleResult.error) { + parts.push(`${input.lifecycleResult.fetched_rows} activity/document rows fetched, ${input.lifecycleResult.matched_rows} matched`); + } + if (input.taxResult && !input.taxResult.error) { + parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`); + } + if (input.receivablesResult && !input.receivablesResult.error) { + parts.push(`${input.receivablesResult.fetched_rows} receivables rows fetched, ${input.receivablesResult.matched_rows} matched`); + } + if (input.payablesResult && !input.payablesResult.error) { + parts.push(`${input.payablesResult.fetched_rows} payables rows fetched, ${input.payablesResult.matched_rows} matched`); + } + if (input.openContractsResult && !input.openContractsResult.error) { + parts.push(`${input.openContractsResult.fetched_rows} open-contract rows fetched, ${input.openContractsResult.matched_rows} matched`); + } + if (input.inventoryOnHandResult && !input.inventoryOnHandResult.error) { + parts.push(`${input.inventoryOnHandResult.fetched_rows} inventory on-hand rows fetched, ${input.inventoryOnHandResult.matched_rows} matched`); + } + if (input.inventoryAgingResult && !input.inventoryAgingResult.error) { + parts.push(`${input.inventoryAgingResult.fetched_rows} inventory aging rows fetched, ${input.inventoryAgingResult.matched_rows} matched`); + } + return parts.length > 0 ? parts.join("; ") : null; +} + +function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDerivedBusinessOverview | null): string[] { + if (!derived) { + return []; + } + const facts: string[] = []; + const organization = derived.organization_scope ? ` по организации ${derived.organization_scope}` : ""; + const period = derived.period_scope ? ` за ${derived.period_scope}` : " за все доступное проверенное окно"; + if (derived.incoming_customer_revenue.rows_with_amount > 0) { + facts.push( + `В 1С подтверждены входящие поступления${organization}${period}: ${derived.incoming_customer_revenue.total_amount_human_ru} по ${derived.incoming_customer_revenue.rows_with_amount} строкам с суммой.` + ); + } + if (derived.outgoing_supplier_payout.rows_with_amount > 0) { + facts.push( + `В 1С подтверждены исходящие платежи/списания${organization}${period}: ${derived.outgoing_supplier_payout.total_amount_human_ru} по ${derived.outgoing_supplier_payout.rows_with_amount} строкам с суммой.` + ); + } + if (derived.top_customers.length > 0) { + const leader = derived.top_customers[0]; + facts.push( + `Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.` + ); + } + if (derived.activity_period) { + facts.push( + `Подтвержденное окно активности в 1С: ${derived.activity_period.first_activity_date} — ${derived.activity_period.latest_activity_date}.` + ); + } + if (derived.tax_position) { + const taxDirection = + derived.tax_position.net_vat_direction === "vat_to_pay" + ? "к уплате" + : derived.tax_position.net_vat_direction === "vat_to_recover_or_offset" + ? "к вычету/зачету" + : "сбалансирован"; + facts.push( + `НДС-позиция за ${derived.tax_position.period_scope} подтверждена по книгам продаж/покупок: продажи ${derived.tax_position.sales_vat_amount_human_ru}, покупки/вычеты ${derived.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${derived.tax_position.net_vat_amount_human_ru}.` + ); + } + if (derived.debt_position) { + const debtDirection = + derived.debt_position.net_debt_position_direction === "net_receivable" + ? "в пользу дебиторки" + : derived.debt_position.net_debt_position_direction === "net_payable" + ? "в сторону кредиторки" + : "сбалансировано"; + facts.push( + `Долговая позиция на ${derived.debt_position.as_of_date} подтверждена по срезам дебиторки/кредиторки 1С: дебиторка ${derived.debt_position.receivables.total_amount_human_ru}, кредиторка ${derived.debt_position.payables.total_amount_human_ru}, нетто ${debtDirection} ${derived.debt_position.net_debt_position_amount_human_ru}.` + ); + } + if (derived.debt_open_settlement_quality) { + const quality = derived.debt_open_settlement_quality; + const leader = quality.top_contracts[0]; + const leaderShareText = leader?.share_of_gross_open_amount_pct === null || leader?.share_of_gross_open_amount_pct === undefined + ? "" + : ` (${leader.share_of_gross_open_amount_pct}%)`; + const leaderText = leader + ? ` Крупнейший открытый договор: ${leader.contract}${leader.counterparty ? ` / ${leader.counterparty}` : ""} — ${leader.total_amount_human_ru}${leaderShareText}.` + : ""; + facts.push( + `Качество открытых расчетов на ${quality.as_of_date} проверено по договорным остаткам 60/62/76: брутто ${quality.gross_open_amount_human_ru}, договоров ${quality.unique_contracts}, контрагентов ${quality.unique_counterparties}.${leaderText}` + ); + } + if (derived.inventory_position) { + const leader = derived.inventory_position.top_items[0]; + const leaderText = leader + ? ` Крупнейшая подтвержденная позиция: ${leader.item} — ${leader.total_amount_human_ru}.` + : ""; + facts.push( + `Складской срез на ${derived.inventory_position.as_of_date} подтвержден по 1С: остаток ${derived.inventory_position.total_amount_human_ru} по ${derived.inventory_position.rows_with_amount} строкам с суммой и ${derived.inventory_position.rows_with_quantity} строкам с количеством.${leaderText}` + ); + if (derived.inventory_position.aging_signal?.oldest_purchase_date) { + const ageText = derived.inventory_position.aging_signal.max_age_days === null + ? "" + : `, максимальный возраст сигнала ${derived.inventory_position.aging_signal.max_age_days} дн.`; + facts.push( + `Возрастной сигнал склада подтвержден по найденным строкам закупок: самая ранняя дата ${derived.inventory_position.aging_signal.oldest_purchase_date}${ageText}.` + ); + } + } + return facts; +} + +function buildBusinessOverviewInferredFacts(derived: AssistantMcpDiscoveryDerivedBusinessOverview | null): string[] { + if (!derived) { + return []; + } + if ( + derived.incoming_customer_revenue.rows_with_amount <= 0 && + derived.outgoing_supplier_payout.rows_with_amount <= 0 + ) { + return []; + } + const direction = + derived.net_direction === "net_incoming" + ? "денежный поток в проверенном срезе больше входящий, чем исходящий" + : derived.net_direction === "net_outgoing" + ? "денежный поток в проверенном срезе больше исходящий, чем входящий" + : "входящий и исходящий денежный поток в проверенном срезе примерно сбалансированы"; + return [ + `Расчетное нетто по найденным строкам: ${derived.net_amount_human_ru}; ${direction}.`, + "Это операционный денежный сигнал по найденным строкам 1С, а не прибыль, маржа или бухгалтерское заключение о здоровье бизнеса." + ]; +} + +function buildBusinessOverviewUnknownFacts(derived: AssistantMcpDiscoveryDerivedBusinessOverview | null): string[] { + const missing = new Set(derived?.missing_signal_families ?? [ + "profit_margin", + "debt_position", + "tax_position", + "inventory_position" + ]); + const unknowns = [ + missing.has("profit_margin") + ? "Прибыль и маржа этим бизнес-обзором не подтверждены: нужны себестоимость, расходы и закрывающие документы." + : null, + missing.has("debt_quality") + ? "Качество дебиторки/кредиторки этим бизнес-обзором не подтверждено: нужен отдельный долговой срез." + : null, + missing.has("debt_position") + ? "Дебиторка/кредиторка этим бизнес-обзором не подтверждены: нужен отдельный долговой срез на явную дату." + : null, + missing.has("debt_aging_quality") + ? "Качество долга и просрочка этим бизнес-обзором не подтверждены: текущий долговой срез показывает только суммы на дату, без aging/due-date анализа." + : null, + missing.has("debt_open_settlement_quality") + ? "Качество открытых расчетов этим бизнес-обзором не подтверждено: нужен срез открытых договоров на явную дату." + : null, + missing.has("debt_due_date_aging_quality") + ? "Просрочка и due-date aging этим бизнес-обзором не подтверждены: открытые договоры показывают концентрацию остатков, но не договорные сроки оплаты." + : null, + missing.has("tax_position") + ? "Налоговая/VAT-позиция этим бизнес-обзором не подтверждена: нужен отдельный налоговый контур или явный проверяемый период." + : null, + missing.has("inventory_health") + ? "Складская ликвидность и товарные остатки этим бизнес-обзором не подтверждены: нужен отдельный inventory-срез." + : null + , + missing.has("inventory_position") + ? "Складской остаток этим бизнес-обзором не подтвержден: нужен отдельный inventory-срез на явную дату." + : null, + missing.has("inventory_aging_quality") + ? "Возраст, залежалость и ликвидность склада этим бизнес-обзором не подтверждены: текущий складской срез показывает только остаток на дату без полноценной оборачиваемости." + : null, + missing.has("inventory_turnover_quality") + ? "Скорость продаж, оборачиваемость и ликвидность склада этим бизнес-обзором не подтверждены: нужен отдельный inventory/продажный анализ, а не только остаток на дату." + : null + ].filter((item): item is string => Boolean(item)); + if (derived?.coverage_limited_by_probe_limit) { + unknowns.unshift("Полное покрытие бизнес-обзора не подтверждено: хотя бы один денежный probe достиг лимита строк."); + } + return unknowns; +} + function buildLifecycleConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { if (result.error || result.matched_rows <= 0) { return []; @@ -2796,6 +3722,7 @@ export async function executeAssistantMcpDiscoveryPilot( const movementPilotEligible = isMovementEvidencePilotEligible(planner); const lifecyclePilotEligible = isLifecyclePilotEligible(planner); const valueFlowPilotEligible = isValueFlowPilotEligible(planner); + const businessOverviewPilotEligible = isBusinessOverviewPilotEligible(planner); const entityResolutionPilotEligible = isEntityResolutionPilotEligible(planner); const inventoryPilotEligible = isInventoryPilotEligible(planner); @@ -2805,6 +3732,7 @@ export async function executeAssistantMcpDiscoveryPilot( !movementPilotEligible && !lifecyclePilotEligible && !valueFlowPilotEligible && + !businessOverviewPilotEligible && !entityResolutionPilotEligible && !inventoryPilotEligible ) { @@ -2988,6 +3916,365 @@ export async function executeAssistantMcpDiscoveryPilot( }; } + if (businessOverviewPilotEligible) { + let incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null; + let outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null; + let lifecycleResult: AddressMcpQueryExecutorResult | null = null; + let taxResult: AddressMcpQueryExecutorResult | null = null; + let receivablesResult: AddressMcpQueryExecutorResult | null = null; + let payablesResult: AddressMcpQueryExecutorResult | null = null; + let openContractsResult: AddressMcpQueryExecutorResult | null = null; + let inventoryOnHandResult: AddressMcpQueryExecutorResult | null = null; + let inventoryAgingResult: AddressMcpQueryExecutorResult | null = null; + const valueFilters = buildValueFlowFilters(planner); + const lifecycleFilters = buildLifecycleFilters(planner); + const taxFilters = buildBusinessOverviewTaxFilters(planner); + const debtFilters = buildBusinessOverviewDebtFilters(planner); + const inventoryFilters = buildBusinessOverviewInventoryFilters(planner); + const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date); + const inventoryAsOfDate = toNonEmptyString(inventoryFilters?.as_of_date); + const incomingSelection = selectAddressRecipe("customer_revenue_and_payments", valueFilters); + const outgoingSelection = selectAddressRecipe("supplier_payouts_profile", valueFilters); + const lifecycleSelection = selectAddressRecipe("counterparty_activity_lifecycle", lifecycleFilters); + const taxSelection = taxFilters + ? selectAddressRecipe("vat_liability_confirmed_for_tax_period", taxFilters) + : null; + const receivablesSelection = debtFilters + ? selectAddressRecipe("receivables_confirmed_as_of_date", debtFilters) + : null; + const payablesSelection = debtFilters + ? selectAddressRecipe("payables_confirmed_as_of_date", debtFilters) + : null; + const openContractsSelection = debtFilters + ? selectAddressRecipe("open_contracts_confirmed_as_of_date", debtFilters) + : null; + const inventoryOnHandSelection = inventoryFilters + ? selectAddressRecipe("inventory_on_hand_as_of_date", inventoryFilters) + : null; + const inventoryAgingSelection = inventoryFilters + ? selectAddressRecipe("inventory_aging_by_purchase_date", inventoryFilters) + : null; + + if (!incomingSelection.selected_recipe || !outgoingSelection.selected_recipe || !lifecycleSelection.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_recipe_not_available"); + const missing = [ + incomingSelection.selected_recipe ? null : "customer_revenue_and_payments", + outgoingSelection.selected_recipe ? null : "supplier_payouts_profile", + lifecycleSelection.selected_recipe ? null : "counterparty_activity_lifecycle" + ].filter((item): item is string => Boolean(item)); + const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "Business overview recipe is not available"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "business_overview_route_template_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + derived_metadata_surface: null, + derived_entity_resolution: null, + derived_activity_period: null, + derived_value_flow: null, + derived_bidirectional_value_flow: null, + query_limitations: [`Business overview recipe is not available: ${missing.join(", ")}`], + reason_codes: reasonCodes + }; + } + + pushReason(reasonCodes, "pilot_business_overview_recipes_selected"); + if (taxSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_tax_recipe_selected"); + } else if (!taxFilters) { + pushReason(reasonCodes, "pilot_business_overview_tax_probe_skipped_without_explicit_period"); + } else { + pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available"); + pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe"); + } + if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_debt_recipes_selected"); + } else if (!debtFilters) { + pushReason(reasonCodes, "pilot_business_overview_debt_probe_skipped_without_explicit_as_of_date"); + } else { + pushReason(reasonCodes, "pilot_business_overview_debt_recipe_not_available"); + pushUnique(queryLimitations, "Business overview debt-position probe requires executable receivables/payables as-of-date recipes"); + } + if (openContractsSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_selected"); + } else if (!debtFilters) { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_probe_skipped_without_explicit_as_of_date"); + } else { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_not_available"); + pushUnique(queryLimitations, "Business overview open-settlement quality probe requires executable open-contracts as-of-date recipe"); + } + if (inventoryOnHandSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_recipe_selected"); + if (inventoryAgingSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_inventory_aging_recipe_selected"); + } + } else if (!inventoryFilters) { + pushReason(reasonCodes, "pilot_business_overview_inventory_probe_skipped_without_explicit_as_of_date"); + } else { + pushReason(reasonCodes, "pilot_business_overview_inventory_recipe_not_available"); + pushUnique(queryLimitations, "Business overview inventory-position probe requires an executable inventory on-hand as-of-date recipe"); + } + for (const step of dryRun.execution_steps) { + if (step.primitive_id === "query_movements") { + const incomingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => buildAddressRecipePlan(incomingSelection.selected_recipe!, scopedFilters), + baseFilters: valueFilters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps: runtimeDeps + }); + const outgoingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => buildAddressRecipePlan(outgoingSelection.selected_recipe!, scopedFilters), + baseFilters: valueFilters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps: runtimeDeps + }); + incomingResult = incomingExecution.result; + outgoingResult = outgoingExecution.result; + if (taxSelection?.selected_recipe) { + const taxPlan = buildAddressRecipePlan(taxSelection.selected_recipe, taxFilters!); + taxResult = await runtimeDeps.executeAddressMcpQuery({ + query: taxPlan.query, + limit: taxPlan.limit, + account_scope: taxPlan.account_scope + }); + } + if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { + const receivablesPlan = buildAddressRecipePlan(receivablesSelection.selected_recipe, debtFilters!); + receivablesResult = await runtimeDeps.executeAddressMcpQuery({ + query: receivablesPlan.query, + limit: receivablesPlan.limit, + account_scope: receivablesPlan.account_scope + }); + const payablesPlan = buildAddressRecipePlan(payablesSelection.selected_recipe, debtFilters!); + payablesResult = await runtimeDeps.executeAddressMcpQuery({ + query: payablesPlan.query, + limit: payablesPlan.limit, + account_scope: payablesPlan.account_scope + }); + } + if (openContractsSelection?.selected_recipe) { + const openContractsPlan = buildAddressRecipePlan(openContractsSelection.selected_recipe, debtFilters!); + openContractsResult = await runtimeDeps.executeAddressMcpQuery({ + query: openContractsPlan.query, + limit: openContractsPlan.limit, + account_scope: openContractsPlan.account_scope + }); + } + if (inventoryOnHandSelection?.selected_recipe) { + const inventoryOnHandPlan = buildAddressRecipePlan(inventoryOnHandSelection.selected_recipe, inventoryFilters!); + inventoryOnHandResult = await runtimeDeps.executeAddressMcpQuery({ + query: inventoryOnHandPlan.query, + limit: inventoryOnHandPlan.limit, + account_scope: inventoryOnHandPlan.account_scope + }); + if (inventoryAgingSelection?.selected_recipe) { + const inventoryAgingPlan = buildAddressRecipePlan(inventoryAgingSelection.selected_recipe, inventoryFilters!); + inventoryAgingResult = await runtimeDeps.executeAddressMcpQuery({ + query: inventoryAgingPlan.query, + limit: inventoryAgingPlan.limit, + account_scope: inventoryAgingPlan.account_scope + }); + } + } + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(...incomingExecution.probe_results, ...outgoingExecution.probe_results); + if (taxResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, taxResult)); + } + if (receivablesResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, receivablesResult)); + } + if (payablesResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, payablesResult)); + } + if (openContractsResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, openContractsResult)); + } + if (inventoryOnHandResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryOnHandResult)); + } + if (inventoryAgingResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryAgingResult)); + } + for (const limitation of [...incomingExecution.query_limitations, ...outgoingExecution.query_limitations]) { + pushUnique(queryLimitations, limitation); + } + if (incomingResult?.error) { + pushReason(reasonCodes, "pilot_business_overview_incoming_query_mcp_error"); + } + if (outgoingResult?.error) { + pushReason(reasonCodes, "pilot_business_overview_outgoing_query_mcp_error"); + } + if (!incomingResult?.error || !outgoingResult?.error) { + pushReason(reasonCodes, "pilot_business_overview_query_movements_mcp_executed"); + } + if (taxResult?.error) { + pushUnique(queryLimitations, taxResult.error); + pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_error"); + } else if (taxResult) { + pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_executed"); + } + if (receivablesResult?.error) { + pushUnique(queryLimitations, receivablesResult.error); + pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_error"); + } else if (receivablesResult) { + pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_executed"); + } + if (payablesResult?.error) { + pushUnique(queryLimitations, payablesResult.error); + pushReason(reasonCodes, "pilot_business_overview_payables_query_mcp_error"); + } else if (payablesResult) { + pushReason(reasonCodes, "pilot_business_overview_payables_query_mcp_executed"); + } + if ( + (receivablesResult && !receivablesResult.error) || + (payablesResult && !payablesResult.error) + ) { + pushReason(reasonCodes, "pilot_business_overview_debt_query_mcp_executed"); + } + if (openContractsResult?.error) { + pushUnique(queryLimitations, openContractsResult.error); + pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_error"); + } else if (openContractsResult) { + pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_executed"); + } + if (inventoryOnHandResult?.error) { + pushUnique(queryLimitations, inventoryOnHandResult.error); + pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_error"); + } else if (inventoryOnHandResult) { + pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_executed"); + } + if (inventoryAgingResult?.error) { + pushUnique(queryLimitations, inventoryAgingResult.error); + pushReason(reasonCodes, "pilot_business_overview_inventory_aging_query_mcp_error"); + } else if (inventoryAgingResult) { + pushReason(reasonCodes, "pilot_business_overview_inventory_aging_query_mcp_executed"); + } + if ( + (inventoryOnHandResult && !inventoryOnHandResult.error) || + (inventoryAgingResult && !inventoryAgingResult.error) + ) { + pushReason(reasonCodes, "pilot_business_overview_inventory_query_mcp_executed"); + } + continue; + } + + if (step.primitive_id === "query_documents") { + const lifecyclePlan = buildAddressRecipePlan(lifecycleSelection.selected_recipe, lifecycleFilters); + lifecycleResult = await runtimeDeps.executeAddressMcpQuery({ + query: lifecyclePlan.query, + limit: lifecyclePlan.limit, + account_scope: lifecyclePlan.account_scope + }); + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, lifecycleResult)); + if (lifecycleResult.error) { + pushUnique(queryLimitations, lifecycleResult.error); + pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_error"); + } else { + pushReason(reasonCodes, "pilot_business_overview_query_documents_mcp_executed"); + } + continue; + } + + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_business_overview_derives_aggregate_coverage_and_explanation")); + } + + const derivedBusinessOverview = deriveBusinessOverview({ + incomingResult, + outgoingResult, + lifecycleResult, + taxResult, + receivablesResult, + payablesResult, + openContractsResult, + debtAsOfDate, + inventoryOnHandResult, + inventoryAgingResult, + inventoryAsOfDate, + organizationScope, + periodScope: dateScope + }); + if (derivedBusinessOverview) { + pushReason(reasonCodes, "pilot_derived_business_overview_from_confirmed_rows"); + if (derivedBusinessOverview.top_customers.length > 0) { + pushReason(reasonCodes, "pilot_derived_business_overview_top_customers_from_confirmed_rows"); + } + if (derivedBusinessOverview.activity_period) { + pushReason(reasonCodes, "pilot_derived_business_overview_activity_window_from_confirmed_rows"); + } + if (derivedBusinessOverview.tax_position) { + pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows"); + } + if (derivedBusinessOverview.debt_position) { + pushReason(reasonCodes, "pilot_derived_business_overview_debt_position_from_confirmed_rows"); + } + if (derivedBusinessOverview.debt_open_settlement_quality) { + pushReason(reasonCodes, "pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows"); + } + if (derivedBusinessOverview.inventory_position) { + pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows"); + } + } + const sourceRowsSummary = summarizeBusinessOverviewRows({ + incomingResult, + outgoingResult, + lifecycleResult, + taxResult, + receivablesResult, + payablesResult, + openContractsResult, + inventoryOnHandResult, + inventoryAgingResult + }); + const evidence = resolveAssistantMcpDiscoveryEvidence({ + plan: planner.discovery_plan, + probeResults, + confirmedFacts: buildBusinessOverviewConfirmedFacts(derivedBusinessOverview), + inferredFacts: buildBusinessOverviewInferredFacts(derivedBusinessOverview), + unknownFacts: buildBusinessOverviewUnknownFacts(derivedBusinessOverview), + sourceRowsSummary, + queryLimitations, + recommendedNextProbe: "explain_evidence_basis" + }); + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "executed", + pilot_scope: "business_overview_route_template_v1", + dry_run: dryRun, + mcp_execution_performed: executedPrimitives.length > 0, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: sourceRowsSummary, + derived_metadata_surface: null, + derived_entity_resolution: null, + derived_activity_period: null, + derived_value_flow: null, + derived_bidirectional_value_flow: null, + derived_business_overview: derivedBusinessOverview, + query_limitations: queryLimitations, + reason_codes: reasonCodes + }; + } + if (metadataPilotEligible) { let metadataResult: AddressMcpMetadataRowsResult | null = null; const metadataScope = metadataScopeForPlanner(planner); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index e4b4f01..23dcea2 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -682,6 +682,12 @@ function hasLifecycleSignal(text: string): boolean { ); } +function hasBusinessOverviewSignal(text: string): boolean { + return /(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u043e\u0431\u0437\u043e\u0440|\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u0430\u0443\u0434\u0438\u0442|\u043f\u043e\u043b\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u0441\u0432\u043e\u0434\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u043a\u0430\u043a\s+\u0442\u044b\s+\u043e\u0446\u0435\u043d(?:\u0438\u0448\u044c|\u0438)\s+\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u0438|\u0438\u044e|\u0438\u044f)\s+\u0432\s+\u0446\u0435\u043b\u043e\u043c|company\s+(?:analysis|overview)|business\s+(?:overview|audit)|llm[-\s]?audit|бизнес[-\s]?РѕР±Р·РѕСЂ|бизнес[-\s]?аудит)/iu.test( + text + ); +} + function hasValueFlowSignal(text: string): boolean { return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|входящ|получ(?:ил|ено|ен)|поступил|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|(? 0 ? followupSeed.metadataAmbiguityEntitySets @@ -1802,7 +1872,9 @@ export function buildAssistantMcpDiscoveryTurnInput( explicit_date_scope: explicitDateScope, subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined, unsupported_but_understood_family: - unsupported ?? + businessOverviewSignal + ? "broad_business_evaluation" + : unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal @@ -1826,6 +1898,7 @@ export function buildAssistantMcpDiscoveryTurnInput( : null), stale_replay_forbidden: Boolean( assistantTurnMeaning?.stale_replay_forbidden || + businessOverviewSignal || unsupported || lifecycleSignal || valueFlowSignal || @@ -1886,7 +1959,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ); const runDiscovery = shouldRunDiscovery({ - unsupported: broadBusinessEvaluationUnsupported ? seededUnsupported : unsupported ?? seededUnsupported, + unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, @@ -1902,6 +1975,7 @@ export function buildAssistantMcpDiscoveryTurnInput( metadataGroundedDocumentLaneApplicable || groundedValueFlowFollowupApplicable, forceDiscoveryOverExplicitIntent: + businessOverviewSignal || Boolean(entityResolutionClarificationCandidate) || organizationClarificationFollowupApplicable || periodClarificationFollowupApplicable || @@ -1976,6 +2050,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (rawAllTimeScopeSignal) { pushReason(reasonCodes, "mcp_discovery_all_time_scope_signal_detected"); } + if (suppressNegatedTaxOnlyDateScope) { + pushReason(reasonCodes, "mcp_discovery_negated_tax_period_scope_suppressed"); + } if (followupDiscoverySeedApplicable) { pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); } @@ -2054,8 +2131,8 @@ export function buildAssistantMcpDiscoveryTurnInput( if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } - if (broadBusinessEvaluationUnsupported) { - pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_kept_in_living_chat"); + if (businessOverviewSignal) { + pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_route_candidate"); } if ( !(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) && diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 7d30040..95c1b29 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -122,6 +122,13 @@ function detectBroadBusinessEvaluation(text) { if (!normalized) { return null; } + if ( + /(?:\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u043e\u0431\u0437\u043e\u0440|\u0431\u0438\u0437\u043d\u0435\u0441[-\s]?\u0430\u0443\u0434\u0438\u0442|\u043f\u043e\u043b\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u0441\u0432\u043e\u0434\u043d\w*\s+\u0430\u043d\u0430\u043b\u0438\u0437\s+(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u0434\u0435\u044f\u0442\u0435\u043b)|\u043a\u0430\u043a\s+\u0442\u044b\s+\u043e\u0446\u0435\u043d(?:\u0438\u0448\u044c|\u0438)\s+\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u0438|\u0438\u044e|\u0438\u044f)\s+\u0432\s+\u0446\u0435\u043b\u043e\u043c|company\s+(?:analysis|overview)|business\s+(?:overview|audit)|llm[-\s]?audit|бизнес[-\s]?РѕР±Р·РѕСЂ|бизнес[-\s]?аудит)/iu.test(normalized) + ) { + return { + family: "broad_business_evaluation" + }; + } if ( /(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|оцени\s+(?:компан|бизнес|деятельност)|(?:полный|сводный|нормальн\w*|взросл\w*)\s+анализ\s+(?:компан|бизнес|деятельност)|проанализируй\s+(?:компан|бизнес|деятельност)|(?:что\s+думаешь|какое\s+мнение)\s+(?:о|по)\s+(?:компан|бизнес)|(?:llm[-\s]?)?аудит\s+(?:компан|бизнес)|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test( normalized @@ -232,7 +239,7 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { asked_domain_family: askedDomainFamily, asked_action_family: askedActionFamily, explicit_intent_candidate: explicitIntentCandidate, - explicit_entity_candidates: buildEntityCandidates(counterpartyTurnover), + explicit_entity_candidates: broadBusinessEvaluation?.family ? [] : buildEntityCandidates(counterpartyTurnover), meaning_confidence: broadBusinessEvaluation?.family ? "medium" : supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 94f20fc..3e93507 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -180,6 +180,283 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.must_not_claim).toContain("Do not present the confirmed movement rows as a complete movement universe."); }); + it("turns business overview multi-probe evidence into an analyst-safe draft", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "all_time_scope", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + unsupported_but_understood_family: "broad_business_evaluation" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }, + { Period: "2020-02-15T00:00:00", Amount: 80000, Counterparty: "Клиент Б" } + ] + }, + { + rows: [{ Period: "2020-01-20T00:00:00", Amount: 150000, Counterparty: "Поставщик А" }] + }, + { + rows: [ + { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, + { Период: "2020-12-15T00:00:00", Регистратор: "Поступление 2" } + ] + } + ]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); + expect(draft.headline).toContain("бизнес-обзор"); + expect(draft.confirmed_lines.join("\n")).toContain("Входящие поступления"); + expect(draft.confirmed_lines.join("\n")).toContain("Самый крупный подтвержденный клиент"); + expect(draft.inference_lines.join("\n")).toContain("не прибыль и не маржа"); + expect(draft.unknown_lines.join("\n")).toContain("Прибыль и маржа"); + expect(draft.unknown_lines.join("\n")).toContain("Налоговая/VAT-позиция"); + expect(draft.must_not_claim).toContain("Do not present business overview cash-flow spread as profit or margin."); + expect(draft.reason_codes).toContain("answer_contains_business_overview"); + }); + + it("surfaces checked VAT/tax position in business overview without treating it as profit", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + unsupported_but_understood_family: "broad_business_evaluation" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, + { + rows: [ + { Регистратор: "VAT_BOOK_SALES", СчетДт: "68.02", Сумма: 40000 }, + { Регистратор: "VAT_BOOK_PURCHASES", СчетДт: "19", Сумма: 12000 } + ] + }, + { + rows: [ + { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, + { Период: "2020-12-15T00:00:00", Регистратор: "Поступление 2" } + ] + } + ]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.headline).toContain("НДС-позиция"); + expect(draft.confirmed_lines.join("\n")).toContain("НДС-позиция за 2020"); + expect(draft.confirmed_lines.join("\n")).toContain("нетто к уплате 28 000 руб."); + expect(draft.inference_lines.join("\n")).toContain("не прибыль и не маржа"); + expect(draft.unknown_lines.join("\n")).not.toContain("Налоговая/VAT-позиция"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_tax_position"); + expect(draft.must_not_claim).toContain("Do not present business overview cash-flow spread as profit or margin."); + }); + + it("surfaces checked debt-position and open-settlement quality without treating them as overdue debt", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + unsupported_but_understood_family: "broad_business_evaluation" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А" } + ] + }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 40000, Counterparty: "Поставщик А" } + ] + }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А", Contract: "Договор А" }, + { Period: "2020-12-31T00:00:00", Amount: 50000, Counterparty: "Поставщик А", Contract: "Договор Б" } + ] + }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }, + { Period: "2020-12-15T00:00:00", Registrator: "Поступление 2" } + ] + } + ]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.headline).toContain("долговой срез"); + expect(draft.headline).toContain("качество открытых расчетов"); + expect(draft.confirmed_lines.join("\n")).toContain("Долговой срез на 2020-12-31"); + expect(draft.confirmed_lines.join("\n")).toContain("Качество открытых расчетов на 2020-12-31"); + expect(draft.confirmed_lines.join("\n")).toContain("нетто"); + expect(draft.unknown_lines.join("\n")).toContain("due-date"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_debt_position"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_open_settlement_quality"); + expect(draft.must_not_claim).toContain("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis."); + expect(draft.must_not_claim).toContain("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt."); + }); + + it("surfaces checked inventory-position snapshot in business overview without treating it as warehouse liquidity", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Тест", + explicit_date_scope: "2020", + unsupported_but_understood_family: "broad_business_evaluation" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" }, + { Period: "2020-12-31T00:00:00", Amount: 50000, Quantity: 5, Item: "Товар Б" } + ] + }, + { + rows: [ + { Period: "2020-01-10T00:00:00", Amount: 200000, Quantity: 8, Item: "Товар А" } + ] + }, + { rows: [{ Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }] } + ]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.headline).toContain("складской срез"); + expect(draft.confirmed_lines.join("\n")).toContain("Складской срез на 2020-12-31"); + expect(draft.confirmed_lines.join("\n")).toContain("Товар А"); + expect(draft.unknown_lines.join("\n")).toContain("оборачиваемость"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_inventory_position"); + expect(draft.must_not_claim).toContain("Do not present an inventory snapshot or purchase-date aging signal as turnover, obsolescence, liquidation value, or full inventory health."); + }); + it("renders metadata-scoped movement all-time follow-up as an all-time bounded answer", async () => { const planner = planAssistantMcpDiscovery({ dataNeedGraph: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index 8d7e675..b478312 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -103,7 +103,7 @@ describe("assistant MCP discovery pilot executor", () => { expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled(); }); - it("keeps business overview as an explicit unsupported runtime scope until the fresh multi-probe bridge exists", async () => { + it("executes business overview as a bounded multi-probe bridge over money flow and activity evidence", async () => { const planner = planAssistantMcpDiscovery({ dataNeedGraph: { schema_version: "assistant_data_need_graph_v1", @@ -134,24 +134,350 @@ describe("assistant MCP discovery pilot executor", () => { explicit_organization_scope: "ООО Альтернатива Плюс" } }); - const deps = buildDeps([]); + const deps = buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }, + { Period: "2020-02-15T00:00:00", Amount: 80000, Counterparty: "Клиент Б" } + ] + }, + { + rows: [ + { Period: "2020-01-20T00:00:00", Amount: 150000, Counterparty: "Поставщик А" } + ] + }, + { + rows: [ + { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, + { Период: "2020-12-15T00:00:00", Регистратор: "Поступление 2" } + ] + } + ]); const result = await executeAssistantMcpDiscoveryPilot(planner, deps); expect(planner.planner_status).toBe("ready_for_execution"); - expect(result.pilot_status).toBe("unsupported"); + expect(result.pilot_status).toBe("executed"); expect(result.pilot_scope).toBe("business_overview_route_template_v1"); - expect(result.mcp_execution_performed).toBe(false); - expect(result.executed_primitives).toEqual([]); + expect(result.mcp_execution_performed).toBe(true); + expect(result.executed_primitives).toEqual(["query_movements", "query_documents"]); expect(result.skipped_primitives).toEqual([ - "query_movements", "aggregate_by_axis", - "query_documents", "probe_coverage", "explain_evidence_basis" ]); - expect(result.reason_codes).toContain("pilot_scope_unsupported_for_live_execution"); - expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled(); + expect(result.derived_business_overview).toMatchObject({ + organization_scope: "ООО Альтернатива Плюс", + incoming_customer_revenue: { + total_amount: 200000, + rows_with_amount: 2 + }, + outgoing_supplier_payout: { + total_amount: 150000, + rows_with_amount: 1 + }, + net_amount: 50000, + net_direction: "net_incoming" + }); + expect(result.derived_business_overview?.top_customers[0]).toMatchObject({ + axis_value: "Клиент А", + total_amount: 120000 + }); + expect(result.derived_business_overview?.activity_period?.duration_total_months).toBe(11); + expect(result.evidence.confirmed_facts.join("\n")).toContain("В 1С подтверждены входящие поступления"); + expect(result.evidence.unknown_facts).toContain( + "Прибыль и маржа этим бизнес-обзором не подтверждены: нужны себестоимость, расходы и закрывающие документы." + ); + expect(result.reason_codes).toContain("pilot_derived_business_overview_from_confirmed_rows"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(3); + }); + + it("adds a checked VAT/tax family to business overview only when an explicit period is available", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }); + const deps = buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" } + ] + }, + { + rows: [ + { Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" } + ] + }, + { + rows: [ + { Регистратор: "VAT_BOOK_SALES", СчетДт: "68.02", Сумма: 40000 }, + { Регистратор: "VAT_BOOK_PURCHASES", СчетДт: "19", Сумма: 12000 } + ] + }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, + { Период: "2020-12-15T00:00:00", Регистратор: "Поступление 2" } + ] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.derived_business_overview?.tax_position).toMatchObject({ + period_scope: "2020", + rows_matched: 2, + rows_with_amount: 2, + sales_vat_amount: 40000, + purchase_vat_amount: 12000, + net_vat_amount: 28000, + net_vat_direction: "vat_to_pay" + }); + expect(result.derived_business_overview?.missing_signal_families).not.toContain("tax_position"); + expect(result.evidence.confirmed_facts.join("\n")).toContain("НДС-позиция за 2020 подтверждена"); + expect(result.evidence.unknown_facts.join("\n")).not.toContain("Налоговая/VAT-позиция этим бизнес-обзором не подтверждена"); + expect(result.reason_codes).toContain("pilot_business_overview_tax_query_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_tax_position_from_confirmed_rows"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(9); + const taxCall = deps.executeAddressMcpQuery.mock.calls[2]?.[0]; + expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПродаж"); + expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПокупок"); + }); + + it("adds a checked debt-position family to business overview only as an as-of-date snapshot", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }); + const deps = buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 70000, Counterparty: "Клиент А" }, + { Period: "2020-12-31T00:00:00", Amount: 30000, Counterparty: "Клиент Б" } + ] + }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 40000, Counterparty: "Поставщик А" } + ] + }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 90000, Counterparty: "Клиент А", Contract: "Договор А" }, + { Period: "2020-12-31T00:00:00", Amount: 30000, Counterparty: "Клиент Б", Contract: "Договор Б" } + ] + }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }, + { Period: "2020-12-15T00:00:00", Registrator: "Поступление 2" } + ] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.derived_business_overview?.debt_position).toMatchObject({ + as_of_date: "2020-12-31", + receivables: { + total_amount: 100000, + rows_with_amount: 2 + }, + payables: { + total_amount: 40000, + rows_with_amount: 1 + }, + net_debt_position_amount: 60000, + net_debt_position_direction: "net_receivable" + }); + expect(result.derived_business_overview?.debt_position?.receivables.top_counterparties[0]).toMatchObject({ + axis_value: "Клиент А", + total_amount: 70000 + }); + expect(result.derived_business_overview?.debt_open_settlement_quality).toMatchObject({ + as_of_date: "2020-12-31", + rows_with_amount: 2, + gross_open_amount: 120000, + unique_counterparties: 2, + unique_contracts: 2, + concentration_top_contract_pct: 75 + }); + expect(result.derived_business_overview?.missing_signal_families).not.toContain("debt_position"); + expect(result.derived_business_overview?.missing_signal_families).not.toContain("debt_open_settlement_quality"); + expect(result.derived_business_overview?.missing_signal_families).toContain("debt_due_date_aging_quality"); + expect(result.evidence.confirmed_facts.join("\n")).toContain("Долговая позиция на 2020-12-31"); + expect(result.evidence.confirmed_facts.join("\n")).toContain("Качество открытых расчетов на 2020-12-31"); + expect(result.evidence.unknown_facts.join("\n")).toContain("due-date"); + expect(result.reason_codes).toContain("pilot_business_overview_debt_query_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_position_from_confirmed_rows"); + expect(result.reason_codes).toContain("pilot_business_overview_open_contracts_query_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(9); + const receivablesCall = deps.executeAddressMcpQuery.mock.calls[3]?.[0]; + const payablesCall = deps.executeAddressMcpQuery.mock.calls[4]?.[0]; + const openContractsCall = deps.executeAddressMcpQuery.mock.calls[5]?.[0]; + expect(String(receivablesCall?.query ?? "")).toContain("62"); + expect(String(payablesCall?.query ?? "")).toContain("60"); + expect(String(openContractsCall?.query ?? "")).toContain("СуммаРазвернутыйОстатокКт"); + }); + + it("adds a checked inventory-position family to business overview only as an as-of-date snapshot", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Тест", + explicit_date_scope: "2020" + } + }); + const deps = buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" }, + { Period: "2020-12-31T00:00:00", Amount: 50000, Quantity: 5, Item: "Товар Б" } + ] + }, + { + rows: [ + { Period: "2020-01-10T00:00:00", Amount: 200000, Quantity: 8, Item: "Товар А" }, + { Period: "2020-11-01T00:00:00", Amount: 50000, Quantity: 2, Item: "Товар Б" } + ] + }, + { + rows: [ + { Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }, + { Period: "2020-12-15T00:00:00", Registrator: "Поступление 2" } + ] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.derived_business_overview?.inventory_position).toMatchObject({ + as_of_date: "2020-12-31", + rows_matched: 2, + rows_with_amount: 2, + rows_with_quantity: 2, + total_amount: 300000, + total_quantity: 15, + aging_signal: { + rows_matched: 2, + rows_with_purchase_date: 2, + oldest_purchase_date: "2020-01-10", + latest_purchase_date: "2020-11-01", + max_age_days: 356 + } + }); + expect(result.derived_business_overview?.inventory_position?.top_items[0]).toMatchObject({ + item: "Товар А", + total_amount: 250000, + total_quantity: 10 + }); + expect(result.derived_business_overview?.missing_signal_families).not.toContain("inventory_position"); + expect(result.derived_business_overview?.missing_signal_families).toContain("inventory_turnover_quality"); + expect(result.evidence.confirmed_facts.join("\n")).toContain("Складской срез на 2020-12-31"); + expect(result.evidence.unknown_facts.join("\n")).toContain("оборачиваемость"); + expect(result.reason_codes).toContain("pilot_business_overview_inventory_query_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_position_from_confirmed_rows"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(9); + const inventoryCall = deps.executeAddressMcpQuery.mock.calls[6]?.[0]; + expect(inventoryCall?.account_scope).toContain("41.01"); }); it("uses the explicit selected chain id when choosing the movement pilot scope", async () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index 67a9ddd..b1dc983 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -221,7 +221,7 @@ describe("assistant MCP discovery planner", () => { } }); - it("builds a catalog-compatible business overview plan without pretending the fresh runtime probe exists yet", () => { + it("builds a catalog-compatible business overview plan for the fresh multi-probe runtime bridge", () => { const result = planAssistantMcpDiscovery({ dataNeedGraph: { schema_version: "assistant_data_need_graph_v1", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index 93f688a..3a65ed5 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -737,4 +737,67 @@ describe("assistant MCP discovery response policy", () => { "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate" ); }); + + it("replaces deterministic broad business evaluation summary with a grounded business overview candidate", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "legacy broad summary", + currentReplySource: "deterministic_broad_business_evaluation_contract", + livingChatSource: "deterministic_broad_business_evaluation_contract", + modeDecisionReason: "unsupported_current_turn_meaning_boundary", + addressRuntimeMeta: { + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }, + data_need_graph: { + business_fact_family: "business_overview", + clarification_gaps: [] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Business overview was assembled from confirmed 1C money flow and activity rows.", + confirmed_lines: [ + "Incoming customer money flow: 200000.00 RUB.", + "Outgoing supplier payouts: 150000.00 RUB.", + "Top confirmed customer by incoming money flow: Client A - 120000.00 RUB." + ], + inference_lines: [ + "Net confirmed cash-flow spread is +50000.00 RUB; this is not profit or margin." + ], + unknown_lines: [ + "Profit and margin are not confirmed by this overview.", + "VAT/tax position is not confirmed by this overview." + ], + limitation_lines: ["Business overview is limited to checked 1C rows."], + next_step_line: "Check profit/margin, debt quality, VAT/tax position, and inventory liquidity." + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_source).toBe("mcp_discovery_response_candidate_guarded"); + expect(result.reply_text).toContain("Incoming customer money flow"); + expect(result.reply_text).toContain("this is not profit or margin"); + expect(result.reply_text).toContain("VAT/tax position is not confirmed"); + expect(result.reply_text).not.toContain("query_movements"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_applied"); + expect(result.reason_codes).not.toContain( + "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate" + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts index 63bf48c..9acb57b 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts @@ -37,6 +37,22 @@ function buildBidirectionalDeps( }; } +function buildSequentialDeps(results: Array<{ rows: Array>; error?: string | null }>) { + const executeAddressMcpQuery = vi.fn(async () => { + const next = results.shift() ?? { rows: [] }; + const rows = next.rows; + const error = next.error ?? null; + return { + fetched_rows: rows.length, + matched_rows: error ? 0 : rows.length, + raw_rows: rows, + rows: error ? [] : rows, + error + }; + }); + return { executeAddressMcpQuery }; +} + function buildMetadataDeps(rows: Array>, error: string | null = null) { return { executeAddressMcpMetadata: vi.fn(async () => ({ @@ -114,6 +130,56 @@ describe("assistant MCP discovery runtime entry point", () => { expect(result.reason_codes).toContain("mcp_discovery_unsupported_but_understood_turn"); }); + it("runs the business overview bridge from broad evaluation turn meaning through multi-probe evidence", async () => { + const deps = buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Client A" }, + { Period: "2020-02-15T00:00:00", Amount: 80000, Counterparty: "Client B" } + ] + }, + { + rows: [{ Period: "2020-01-20T00:00:00", Amount: 150000, Counterparty: "Supplier A" }] + }, + { + rows: [ + { Period: "2020-01-15T00:00:00", Registrar: "Customer payment 1" }, + { Period: "2020-12-15T00:00:00", Registrar: "Customer payment 2" } + ] + } + ]); + + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + assistantTurnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "Alternative Plus", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }, + deps + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.turn_input.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.turn_input.data_need_graph?.clarification_gaps).toEqual([]); + expect(result.bridge?.bridge_status).toBe("answer_draft_ready"); + expect(result.bridge?.pilot.pilot_scope).toBe("business_overview_route_template_v1"); + expect(result.bridge?.pilot.derived_business_overview).toMatchObject({ + organization_scope: "Alternative Plus", + net_amount: 50000, + net_direction: "net_incoming" + }); + expect(result.bridge?.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference"); + expect(result.bridge?.answer_draft.confirmed_lines.join("\n")).toContain("Client A"); + expect(result.bridge?.answer_draft.inference_lines.join("\n")).toContain("\u043d\u0435 \u043f\u0440\u0438\u0431\u044b\u043b\u044c"); + expect(result.bridge?.answer_draft.unknown_lines.join("\n")).toContain("VAT"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_from_confirmed_rows"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(3); + }); + it("runs the bridge for raw metadata wording without an exact route owner", async () => { const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ userMessage: "какие документы и поля есть в 1С по НДС?", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 41c1257..9373d75 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1409,7 +1409,7 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); }); - it("does not replace broad business evaluation with metadata discovery", () => { + it("routes broad business evaluation into business overview discovery without metadata drift", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "\u041a\u0430\u043a \u0442\u044b \u043e\u0446\u0435\u043d\u0438\u0448\u044c \u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438?", @@ -1422,11 +1422,117 @@ describe("assistant MCP discovery turn input adapter", () => { } }); - expect(result.adapter_status).toBe("not_applicable"); - expect(result.should_run_discovery).toBe(false); - expect(result.turn_meaning_ref).toBeNull(); - expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_kept_in_living_chat"); - expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.clarification_gaps).toContain("organization"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_date_scope: "2026-05-01", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); + expect(result.reason_codes).toContain("mcp_discovery_data_need_graph_built"); + expect(result.reason_codes).not.toContain("mcp_discovery_metadata_signal_detected"); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + + it("lets raw business-overview wording override stale exact turnover meaning", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u0414\u0430\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u043f\u043e \u0434\u0430\u043d\u043d\u044b\u043c 1\u0421: \u043e\u0431\u043e\u0440\u043e\u0442\u044b, \u043d\u0435\u0442\u0442\u043e, \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c.", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + explicit_intent_candidate: "customer_revenue_and_payments", + explicit_entity_candidates: [{ value: "\u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e." }], + stale_replay_forbidden: false + }, + predecomposeContract: { + entities: { organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441" }, + period: { period_to: "2026-05-03" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.time_scope_need).toBe("all_time_scope"); + expect(result.data_need_graph?.clarification_gaps).toEqual([]); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); + expect(result.reason_codes).not.toContain("mcp_discovery_value_flow_signal_detected"); + }); + + it("keeps explicit year out of the organization scope for raw business overview wording", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "Дай бизнес-обзор ООО Альтернатива Плюс за 2020 год по данным 1С: деньги, нетто, активность, дебиторка и кредиторка на дату.", + assistantTurnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }, + predecomposeContract: { + entities: { organization: "ООО Альтернатива Плюс" }, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.time_scope_need).toBe("explicit_period"); + expect(result.turn_meaning_ref).toMatchObject({ + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + }); + }); + + it("keeps all-time business overview from reusing a negated VAT period as active scope", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u0422\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 \u0432\u0441\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f \u0434\u0430\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u0432 \u0446\u0435\u043b\u043e\u043c, \u043d\u043e \u043d\u0435 \u0442\u0430\u0449\u0438 \u041d\u0414\u0421 \u0437\u0430 2020 \u043a\u0430\u043a \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u0443\u044e \u043e\u0431\u0449\u0443\u044e \u043d\u0430\u043b\u043e\u0433\u043e\u0432\u0443\u044e \u043f\u043e\u0437\u0438\u0446\u0438\u044e.", + assistantTurnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_date_scope: "2020-12-31", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }, + predecomposeContract: { + entities: { organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441" }, + period: { period_to: "2020-12-31" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.time_scope_need).toBe("all_time_scope"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined(); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).toContain("mcp_discovery_all_time_scope_signal_detected"); + expect(result.reason_codes).toContain("mcp_discovery_negated_tax_period_scope_suppressed"); }); it("does not bootstrap metadata discovery from a referential document exclusion follow-up over exact document context", () => { diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index 462654b..e95865b 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -126,4 +126,23 @@ describe("assistantTurnMeaningPolicy", () => { expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation"); expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); }); + + it("lets explicit business overview wording beat turnover and net-flow cues", () => { + const policy = buildPolicy({ + resolveAddressIntent: () => ({ intent: "customer_revenue_and_payments", confidence: "high" }) + }); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u0414\u0430\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441: \u043e\u0431\u043e\u0440\u043e\u0442\u044b, \u043d\u0435\u0442\u0442\u043e, \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c." + }); + + expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.asked_domain_family).toBe("business_summary"); + expect(meaning.asked_action_family).toBe("broad_evaluation"); + expect(meaning.explicit_entity_candidates).toEqual([]); + expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation"); + expect(meaning.stale_replay_forbidden).toBe(true); + expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); + }); });