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 141d590..c3cbcce 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 @@ -83,10 +83,11 @@ Fresh validation cut: - Completed autonomy slice inside that loop: `Proof-Family Enablement Candidates`: exact organization-level profit/margin, overdue/due-date debt aging, inventory reserve/liquidation, and vendor-risk/procurement-quality asks remain user-safe while route candidates mark the missing reviewed proof families as `needs_route_enablement`. - Completed autonomy slice inside that loop: `Accounting Profit-Margin Reviewed Route`: `accounting_profit_margin` is now promoted from `needs_route_enablement` into a reviewed 90/91/99 accounting-result route with accepted live replay. - Completed autonomy slice inside that loop: `Debt Due-Date Aging Reviewed Route`: `debt_due_date_aging_quality` is now promoted from proxy-only route-candidate gap into a reviewed payment-term/open-balance route with accepted live replay. -- Current live canary: `phase94_debt_due_date_aging_reviewed_route_live4` accepted `7/7`. -- Current accepted autorun: `AGENT | Phase 94 debt due-date aging reviewed route` (`gen-ag05101319-c04f79`). +- Completed autonomy slice inside that loop: `Vendor/Procurement Quality Reviewed Route`: `vendor_risk_procurement_quality` now promotes to reviewed procurement-concentration evidence when confirmed outgoing payment, bank-like recipient segregation, non-financial recipient, counterparty-role, and contract-usage signals are reachable; phase95 live replay is accepted. +- Current live canary: `phase95_vendor_procurement_quality_reviewed_route_live2` accepted `7/7`. +- Current accepted autorun: `AGENT | Phase 95 vendor/procurement quality reviewed route` (`gen-ag05121357-9ea5d6`). - Implementation breadth: `~99% (Open-World Bounded Autonomy Breadth through Slice 25)`. -- Next active slice: select the next phase92 proof family, likely vendor/procurement quality or inventory reserve/liquidation, and drive it through repair -> reviewed route enablement -> rerun, without treating proxy-only evidence as proof. +- Next active slice: select the remaining phase92 proof family `inventory_reserve_liquidation_quality`. - Active module progress: `~99% (Agentic Semantic Development Loop, accepted dogfood loop + autorun hygiene; manual GUI confirmation still required)`. ## Reporting Rule @@ -98,7 +99,7 @@ Use these labels when reporting progress: - `Прогресс модуля: 99% (Open-World Bounded Autonomy Breadth, active slice: Semantic Control Gate)` while discussing current module closure after the EHMO-derived critical subset accepted live again with W5/W7 hardening. - `Прогресс модуля: 99% (Agentic Semantic Development Loop, accepted dogfood loop + autorun hygiene; manual GUI confirmation still required)` when discussing the current development-loop operating layer. - `Прогресс модуля: 100% (Open-World Route Candidate Promotion, declared phase90 slice accepted)` when discussing the route-candidate handoff slice itself. -- `Прогресс модуля: 84% (Route-Candidate-Driven Enablement Loop, active slice: second reviewed proof-family route accepted)` when discussing the current candidate-driven enablement loop. +- `Прогресс модуля: 92% (Route-Candidate-Driven Enablement Loop, active slice: third reviewed proof-family route accepted)` when discussing the current candidate-driven enablement loop. - `Open-World Business Overview implementation breadth: ~99%, Semantic Control Gate critical subset accepted, fat GUI pack still pending` when discussing only the already wired Slice 25 breadth. - `Прогресс модуля: X% (Open-World Bounded Autonomy Breadth, active slice: )` for later breadth work after the Semantic Control Gate is accepted. @@ -134,7 +135,7 @@ Remaining work belongs to the next breadth module: - confirm the latest autorun Cyrillic hygiene cut in the GUI after backend refresh and inspect frontend/API payloads if old replacement characters remain visible; - continue dogfooding the `Agentic Semantic Development Loop` on real stage packs, especially generated-question quality, semantic business audit, repair handoff, and rerun acceptance; - finish closure of the `Open-World Semantic Control Gate` opened by `assistant-stage1-EHMOy3lNFt`; the EHMO-derived critical subset is accepted live after W5/W7 hardening, but the fat GUI pack and residual answer-shape roughness still need final review; -- extend `business_overview` beyond money-flow/activity, customer and supplier concentration, document/account-section activity mix, counterparty role split, contract usage, yearly operating-flow dynamics, explicit profit/margin wording boundaries, explicit debt due-date wording boundaries, explicit inventory reserve/liquidation wording boundaries, explicit supplier/procurement-quality wording boundaries, explicit-period VAT/tax, as-of-date debt position, open-settlement concentration, contract-date debt age, debt staleness-risk proxy, as-of-date inventory position, trading-margin proxy, sales-to-stock inventory proxy, warehouse staleness-risk proxy, the missing-proof ledger, the reviewed accounting profit/margin route, and the reviewed debt due-date aging route into separately proven vendor-risk/procurement-quality analysis and confirmed reserve/write-off/liquidation inventory evidence families; +- extend `business_overview` beyond money-flow/activity, customer and supplier concentration, document/account-section activity mix, counterparty role split, contract usage, yearly operating-flow dynamics, explicit profit/margin wording boundaries, explicit debt due-date wording boundaries, explicit inventory reserve/liquidation wording boundaries, explicit supplier/procurement-quality wording boundaries, explicit-period VAT/tax, as-of-date debt position, open-settlement concentration, contract-date debt age, debt staleness-risk proxy, as-of-date inventory position, trading-margin proxy, sales-to-stock inventory proxy, warehouse staleness-risk proxy, the missing-proof ledger, the reviewed accounting profit/margin route, the reviewed debt due-date aging route, and the reviewed vendor/procurement concentration route into confirmed reserve/write-off/liquidation inventory 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/26 - route_candidate_driven_enablement_loop_2026-05-10.md b/docs/ARCH/11 - architecture_turnaround/26 - route_candidate_driven_enablement_loop_2026-05-10.md index 47b2126..946402c 100644 --- a/docs/ARCH/11 - architecture_turnaround/26 - route_candidate_driven_enablement_loop_2026-05-10.md +++ b/docs/ARCH/11 - architecture_turnaround/26 - route_candidate_driven_enablement_loop_2026-05-10.md @@ -50,6 +50,8 @@ Live semantic replay: - accepted user-runnable autorun for that route replay: `AGENT | Phase 93 accounting profit-margin reviewed route` (`gen-ag05101213-596d99`). - second reviewed proof-family route replay: `artifacts/domain_runs/phase94_debt_due_date_aging_reviewed_route_live4`, `7/7` passed, `0` warnings, `0` failures. - accepted user-runnable autorun for that route replay: `AGENT | Phase 94 debt due-date aging reviewed route` (`gen-ag05101319-c04f79`). +- third proof-family route replay: `artifacts/domain_runs/phase95_vendor_procurement_quality_reviewed_route_live2`, `7/7` passed, `0` warnings, `0` failures. +- accepted user-runnable autorun for that route replay: `AGENT | Phase 95 vendor/procurement quality reviewed route` (`gen-ag05121357-9ea5d6`). The replay proves the user-facing route-candidate canary remains healthy while the development tooling starts treating route candidates as repair-loop input: @@ -61,28 +63,30 @@ The replay proves the user-facing route-candidate canary remains healthy while t - exact P&L, due-date debt aging, vendor-risk/procurement-quality, and reserve/liquidation wording now surface as concrete `needs_route_enablement` proof-family candidates when the reviewed route is missing. - exact accounting profit/margin wording now has the first reviewed route implementation and can move to `ready_for_reviewed_execution` through confirmed 90/91/99 accounting rows. - exact due-date debt aging wording now has the second reviewed route implementation and can move to `ready_for_reviewed_execution` through confirmed open-balance/payment-term evidence, while absent payment terms produce an honest checked-negative boundary answer. +- exact vendor-risk/procurement wording now has a live-accepted reviewed procurement-concentration route that can move to `ready_for_reviewed_execution` when confirmed outgoing payment, bank-like recipient segregation, non-financial recipient, counterparty-role, and contract-usage evidence are reachable. It still does not prove supplier reliability, delivery quality, payment purpose, contract-term compliance, or full expense structure. ## Status Current module wording: -`Route-Candidate-Driven Enablement Loop, active slice: second reviewed proof-family route accepted` +`Route-Candidate-Driven Enablement Loop, active slice: third reviewed proof-family route accepted` -Progress: `84%`. +Progress: `92%`. -The first cut proved the handoff mechanics and live canary. The second cut proved real proof-family candidates and a saved accepted AGENT pack. The third cut proved the intended promotion loop on `accounting_profit_margin`. The fourth cut proves the same loop on `debt_due_date_aging_quality`, including short boundary follow-up continuity and saved accepted autorun hygiene. The module is still not complete because vendor/procurement quality and inventory reserve/liquidation need the same treatment or an explicit bounded non-implementation decision. +The first cut proved the handoff mechanics and live canary. The second cut proved real proof-family candidates and a saved accepted AGENT pack. The third cut proved the intended promotion loop on `accounting_profit_margin`. The fourth cut proved the same loop on `debt_due_date_aging_quality`, including short boundary follow-up continuity and saved accepted autorun hygiene. The fifth cut proves `vendor_risk_procurement_quality` as reviewed procurement-concentration evidence with accepted phase95 replay. The module is still not complete because the inventory reserve/liquidation proof family remains open. ## Next Work Next slices: -1. Pick the next high-value proof family from phase92: `vendor_risk_procurement_quality` or `inventory_reserve_liquidation_quality`. -2. Implement the smallest reviewed route/capability for that proof family only if reliable 1C evidence is reachable. -3. Rerun phase94 as a canary plus a focused route-specific pack until the selected candidate changes from `needs_route_enablement` to `ready_for_reviewed_execution` or a clearly bounded non-implementation decision. -4. Save the accepted pack into autoruns only after the live replay and semantic review pass. +1. Pick the final phase92 proof family: `inventory_reserve_liquidation_quality`. +2. Implement or explicitly bound that final family only if reliable 1C evidence is reachable. +3. Rerun phase95 as a canary plus the focused inventory route-specific pack. +4. Save the accepted pack into autoruns only after live replay and semantic review pass. See also: - [27 - proof_family_enablement_candidates_2026-05-10.md](./27%20-%20proof_family_enablement_candidates_2026-05-10.md) - [28 - accounting_profit_margin_reviewed_route_2026-05-10.md](./28%20-%20accounting_profit_margin_reviewed_route_2026-05-10.md) - [29 - debt_due_date_aging_reviewed_route_2026-05-10.md](./29%20-%20debt_due_date_aging_reviewed_route_2026-05-10.md) +- [30 - vendor_procurement_quality_reviewed_route_2026-05-12.md](./30%20-%20vendor_procurement_quality_reviewed_route_2026-05-12.md) diff --git a/docs/ARCH/11 - architecture_turnaround/27 - proof_family_enablement_candidates_2026-05-10.md b/docs/ARCH/11 - architecture_turnaround/27 - proof_family_enablement_candidates_2026-05-10.md index 40fc400..2c8714a 100644 --- a/docs/ARCH/11 - architecture_turnaround/27 - proof_family_enablement_candidates_2026-05-10.md +++ b/docs/ARCH/11 - architecture_turnaround/27 - proof_family_enablement_candidates_2026-05-10.md @@ -84,27 +84,40 @@ The second candidate from this slice has now been promoted: This means `debt_due_date_aging_quality` is no longer only a proxy-only proof-family candidate. It is now a reviewed executable route that can produce checked negative answers when payment terms are not configured. +## Phase95 Follow-Up + +The third candidate from this slice has now been promoted: + +- proof family: `vendor_risk_procurement_quality`; +- reviewed evidence boundary: procurement/outgoing-payment concentration inside `business_overview`; +- evidence basis: confirmed outgoing payment rows, top outgoing recipient, top non-financial recipient, counterparty role profile, and contract usage profile; +- local validation: executor/runtime bridge/answer/candidate tests passed `118/118` with `1` skipped; `npm.cmd run build` passed; +- accepted run: `artifacts/domain_runs/phase95_vendor_procurement_quality_reviewed_route_live2`, `7/7` passed; +- accepted autorun: `AGENT | Phase 95 vendor/procurement quality reviewed route` (`gen-ag05121357-9ea5d6`). + +This means `vendor_risk_procurement_quality` is no longer only a missing-proof candidate when reviewed procurement-concentration evidence is reachable. It is still not a full vendor-risk due-diligence engine: supplier reliability, delivery quality, payment purpose, contract-term compliance, and complete expense structure remain unproved. + ## Status Current module wording: -`Route-Candidate-Driven Enablement Loop, active slice: second reviewed proof-family route accepted` +`Route-Candidate-Driven Enablement Loop, active slice: third reviewed proof-family route accepted` -Progress: `84%`. +Progress: `92%`. -This cut proved the missing-proof candidate surface and accepted user-runnable AGENT canary. Phase93 then implemented the first exact reviewed route for the accounting profit/margin family. Phase94 implemented the second reviewed route for due-date debt aging and verified short boundary follow-up continuity. The remaining closure work is to repeat that discipline for vendor/procurement quality and inventory reserve/liquidation, or explicitly bound them if exact evidence is not reachable. +This cut proved the missing-proof candidate surface and accepted user-runnable AGENT canary. Phase93 then implemented the first exact reviewed route for the accounting profit/margin family. Phase94 implemented the second reviewed route for due-date debt aging and verified short boundary follow-up continuity. Phase95 now promotes vendor/procurement quality through reviewed procurement-concentration evidence and accepted live replay. The remaining closure work is inventory reserve/liquidation enablement or an explicit bounded non-implementation decision. ## Next Work Next slices: -1. Pick the next high-value proof family from the accepted phase92 candidate set: vendor/procurement quality or inventory reserve/liquidation. -2. Wire the smallest reviewed exact route/capability for that family, starting with the data that is already reachable through MCP/1C. -3. Keep the existing proxy answer as a fallback boundary, not as the final proof. -4. Rerun the phase94 canary plus a focused route-specific pack until the selected candidate moves from `needs_route_enablement` to `ready_for_reviewed_execution` or an explicit non-implementation boundary. -5. Save the new AGENT autorun only after the live replay and semantic review pass. +1. Wire or explicitly bound the final remaining family: `inventory_reserve_liquidation_quality`. +2. Keep proxy-only inventory reserve/liquidation wording bounded until reviewed evidence exists. +3. Rerun phase95 as a canary plus the focused inventory route-specific pack. +4. Save the next AGENT autorun only after live replay and semantic review pass. See also: - [28 - accounting_profit_margin_reviewed_route_2026-05-10.md](./28%20-%20accounting_profit_margin_reviewed_route_2026-05-10.md) - [29 - debt_due_date_aging_reviewed_route_2026-05-10.md](./29%20-%20debt_due_date_aging_reviewed_route_2026-05-10.md) +- [30 - vendor_procurement_quality_reviewed_route_2026-05-12.md](./30%20-%20vendor_procurement_quality_reviewed_route_2026-05-12.md) diff --git a/docs/ARCH/11 - architecture_turnaround/28 - accounting_profit_margin_reviewed_route_2026-05-10.md b/docs/ARCH/11 - architecture_turnaround/28 - accounting_profit_margin_reviewed_route_2026-05-10.md index 3ec10c4..c1968ea 100644 --- a/docs/ARCH/11 - architecture_turnaround/28 - accounting_profit_margin_reviewed_route_2026-05-10.md +++ b/docs/ARCH/11 - architecture_turnaround/28 - accounting_profit_margin_reviewed_route_2026-05-10.md @@ -81,18 +81,16 @@ This keeps the phase93 accounting route as a canary while proving the candidate- Current module wording: -`Route-Candidate-Driven Enablement Loop, active slice: second reviewed proof-family route accepted` +`Route-Candidate-Driven Enablement Loop, active slice: third reviewed proof-family route accepted` -Progress: `84%`. +Progress: `92%`. -This was the first proof that the loop can turn a route candidate into an executable reviewed route. Phase94 has now repeated the pattern for due-date debt aging. The module is not yet complete because vendor/procurement quality and inventory reserve/liquidation still need the same treatment or an explicit bounded non-implementation decision. +This was the first proof that the loop can turn a route candidate into an executable reviewed route. Phase94 repeated the pattern for due-date debt aging, and phase95 accepted vendor/procurement quality through reviewed procurement-concentration evidence. The module is not yet complete because inventory reserve/liquidation still needs the same treatment or an explicit bounded non-implementation decision. ## Next Work Next slices: -1. Pick the next phase92 proof family: `vendor_risk_procurement_quality` or `inventory_reserve_liquidation_quality`. -2. Identify reachable 1C evidence for the selected family, or an honest fallback if the contour cannot prove it exactly. -3. Wire the smallest reviewed route/capability only if the evidence is reliable enough. -4. Rerun the phase94 canary plus a focused route-specific pack until the new route is either `ready_for_reviewed_execution` or explicitly bounded. -5. Save the next AGENT autorun only after live replay and semantic review pass. +1. Select the remaining proof family: `inventory_reserve_liquidation_quality`. +2. Wire only the smallest reliable reviewed route, not a broad heuristic. +3. Keep proxy-only reserve/liquidation wording bounded until the route is accepted. diff --git a/docs/ARCH/11 - architecture_turnaround/29 - debt_due_date_aging_reviewed_route_2026-05-10.md b/docs/ARCH/11 - architecture_turnaround/29 - debt_due_date_aging_reviewed_route_2026-05-10.md index 83d091a..5aba3fe 100644 --- a/docs/ARCH/11 - architecture_turnaround/29 - debt_due_date_aging_reviewed_route_2026-05-10.md +++ b/docs/ARCH/11 - architecture_turnaround/29 - debt_due_date_aging_reviewed_route_2026-05-10.md @@ -77,20 +77,17 @@ The accepted replay proves: Current module wording: -`Route-Candidate-Driven Enablement Loop, active slice: second reviewed proof-family route accepted` +`Route-Candidate-Driven Enablement Loop, active slice: third reviewed proof-family route accepted` -Progress: `84%`. +Progress: `92%`. -This is the second proof that the loop can turn a route candidate into an executable reviewed route. The module is not yet complete because vendor/procurement quality and inventory reserve/liquidation still need either reviewed exact routes or explicit bounded non-implementation decisions. +This is the second live-accepted proof that the loop can turn a route candidate into an executable reviewed route. Phase95 now accepts `vendor_risk_procurement_quality` through reviewed procurement-concentration evidence. The module is not yet complete because inventory reserve/liquidation still needs either reviewed exact route enablement or an explicit bounded non-implementation decision. ## Next Work Next slices: -1. Pick the next phase92 proof family: either `vendor_risk_procurement_quality` or `inventory_reserve_liquidation_quality`. -2. Identify whether reliable 1C evidence exists for the chosen family. -3. Wire the smallest reviewed route only if it can prove the business claim without overreach. -4. Keep proxy-only evidence bounded if exact proof is not reachable. -5. Rerun phase94 as a canary plus the focused next route-specific pack. -6. Save the next AGENT autorun only after accepted live replay and semantic review. - +1. Identify whether reliable 1C evidence exists for `inventory_reserve_liquidation_quality`. +2. Wire the smallest reviewed route only if it can prove the inventory business claim without overreach. +3. Keep proxy-only evidence bounded if exact proof is not reachable. +4. Rerun phase95 as a canary plus the focused inventory route-specific pack. diff --git a/docs/ARCH/11 - architecture_turnaround/30 - vendor_procurement_quality_reviewed_route_2026-05-12.md b/docs/ARCH/11 - architecture_turnaround/30 - vendor_procurement_quality_reviewed_route_2026-05-12.md new file mode 100644 index 0000000..0efd844 --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/30 - vendor_procurement_quality_reviewed_route_2026-05-12.md @@ -0,0 +1,90 @@ +# 30 - Vendor/Procurement Quality Reviewed Route (2026-05-12) + +## Purpose + +This note records the third proof-family promotion inside the `Route-Candidate-Driven Enablement Loop`. + +The selected proof family is `vendor_risk_procurement_quality`. + +The implementation deliberately does not pretend to be a full vendor-risk due-diligence engine. The reachable reviewed evidence is procurement/outgoing-payment concentration, supported by counterparty-role and contract-usage profiles. + +The intended answer is: + +- direct enough for supplier/procurement dependency questions; +- honest about bank-like counterparties such as `СБЕРБАНК, ПАО`; +- explicit that supplier reliability, delivery quality, payment purpose, contract terms, and full expense structure are not proven by this route. + +## Implementation Cut + +Implemented locally: + +- `business_overview` now derives `vendor_procurement_quality` from confirmed outgoing payment rows, ranked outgoing counterparties, counterparty role profile, and contract usage profile; +- the derivation records: + - total outgoing amount and row count; + - top outgoing recipient and share; + - top non-financial recipient and share; + - whether a bank/financial institution leads the outgoing cash concentration; + - supplier-only/mixed-role counts and contract usage counters when available; + - evidence status: `reviewed_procurement_concentration`, `financial_institution_leads_outgoing_cash`, or `insufficient_supplier_concentration_basis`; +- `vendor_risk_procurement_quality` is no longer left in `missing_proof_families` when this reviewed procurement-concentration evidence exists; +- route-candidate status can now move to `ready_for_reviewed_execution` for `vendor_risk_procurement_boundary` instead of staying in `needs_route_enablement`; +- user-facing response candidates and answer drafts prefer a direct procurement-concentration explanation over the old generic proxy wording; +- bank-like outgoing leaders are separated from ordinary supplier dependency rather than being presented as normal suppliers. + +## Boundaries + +Still not claimed: + +- supplier reliability; +- delivery quality; +- payment purpose for each payment; +- contract-term compliance; +- SLA/procurement due diligence; +- full expense structure. + +This slice proves a reviewed procurement concentration boundary, not a complete vendor-risk audit. + +## Validation + +Local validation: + +- `npm.cmd test -- assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryResponseCandidate.test.ts` passed `118/118` with `1` skipped; +- `npm.cmd run build` passed. + +Semantic/live replay: + +- spec: `docs/orchestration/address_truth_harness_phase95_vendor_procurement_quality_reviewed_route.json`; +- accepted run: `artifacts/domain_runs/phase95_vendor_procurement_quality_reviewed_route_live2`; +- final status: `accepted`, `7/7` passed, `0` warnings, `0` failures; +- accepted autorun: `AGENT | Phase 95 vendor/procurement quality reviewed route` (`gen-ag05121357-9ea5d6`). + +## Status + +Current module wording: + +`Route-Candidate-Driven Enablement Loop, active slice: third reviewed proof-family route accepted` + +Progress: `92%`. + +The loop has now promoted three phase92 proof families from candidate gaps into reviewed execution or locally reviewed execution: + +- `accounting_profit_margin` accepted live by phase93; +- `debt_due_date_aging_quality` accepted live by phase94; +- `vendor_risk_procurement_quality` accepted live through procurement-concentration evidence by phase95. + +The module is still not complete because the remaining `inventory_reserve_liquidation_quality` family still needs either reviewed-route enablement or an explicit bounded non-implementation decision. + +## Next Work + +Next slices: + +1. Select the final remaining phase92 family: `inventory_reserve_liquidation_quality`. +2. Determine whether reachable 1C evidence can prove reserve/write-off/liquidation quality without overreach. +3. If yes, wire the smallest reviewed route and run a focused phase96 semantic replay. +4. If no, record an explicit bounded non-implementation decision and keep the proxy answer honest. + +See also: + +- [26 - route_candidate_driven_enablement_loop_2026-05-10.md](./26%20-%20route_candidate_driven_enablement_loop_2026-05-10.md) +- [27 - proof_family_enablement_candidates_2026-05-10.md](./27%20-%20proof_family_enablement_candidates_2026-05-10.md) +- [29 - debt_due_date_aging_reviewed_route_2026-05-10.md](./29%20-%20debt_due_date_aging_reviewed_route_2026-05-10.md) diff --git a/docs/ARCH/11 - architecture_turnaround/README.md b/docs/ARCH/11 - architecture_turnaround/README.md index 238de9a..8c70408 100644 --- a/docs/ARCH/11 - architecture_turnaround/README.md +++ b/docs/ARCH/11 - architecture_turnaround/README.md @@ -47,6 +47,7 @@ This package answers the next question: 27. [27 - proof_family_enablement_candidates_2026-05-10.md](./27%20-%20proof_family_enablement_candidates_2026-05-10.md) 28. [28 - accounting_profit_margin_reviewed_route_2026-05-10.md](./28%20-%20accounting_profit_margin_reviewed_route_2026-05-10.md) 29. [29 - debt_due_date_aging_reviewed_route_2026-05-10.md](./29%20-%20debt_due_date_aging_reviewed_route_2026-05-10.md) +30. [30 - vendor_procurement_quality_reviewed_route_2026-05-12.md](./30%20-%20vendor_procurement_quality_reviewed_route_2026-05-12.md) ## Current Status Snapshot (2026-05-10) @@ -100,6 +101,8 @@ Status canon for planning: - The accepted user-runnable autorun for that slice is `AGENT | Phase 93 accounting profit-margin reviewed route` (`gen-ag05101213-596d99`). - The second proof-family route is now implemented and accepted: `debt_due_date_aging_quality` moved from proxy-only route-candidate gap to reviewed payment-term/open-balance execution; `phase94_debt_due_date_aging_reviewed_route_live4` passed `7/7`. - The accepted user-runnable autorun for that slice is `AGENT | Phase 94 debt due-date aging reviewed route` (`gen-ag05101319-c04f79`). +- The third proof-family route is now implemented and accepted: `vendor_risk_procurement_quality` moves from missing proof-family gap into reviewed procurement-concentration evidence when outgoing payment, bank-like recipient, non-financial recipient, counterparty-role, and contract-usage signals are reachable; `phase95_vendor_procurement_quality_reviewed_route_live2` passed `7/7`. +- The accepted user-runnable autorun for that slice is `AGENT | Phase 95 vendor/procurement quality reviewed route` (`gen-ag05121357-9ea5d6`). - The phase94 replay spec was repaired to real UTF-8 Russian before autorun persistence, so the saved user-runnable pack does not repeat the earlier GUI mojibake/card-text regression. - 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). - The current execution spine after EHMO is [23 - current_execution_spine_and_semantic_control_gate_2026-05-05.md](./23%20-%20current_execution_spine_and_semantic_control_gate_2026-05-05.md). @@ -109,6 +112,7 @@ Status canon for planning: - The current proof-family enablement-candidate slice is [27 - proof_family_enablement_candidates_2026-05-10.md](./27%20-%20proof_family_enablement_candidates_2026-05-10.md). - The current first reviewed proof-family route slice is [28 - accounting_profit_margin_reviewed_route_2026-05-10.md](./28%20-%20accounting_profit_margin_reviewed_route_2026-05-10.md). - The current second reviewed proof-family route slice is [29 - debt_due_date_aging_reviewed_route_2026-05-10.md](./29%20-%20debt_due_date_aging_reviewed_route_2026-05-10.md). +- The current third reviewed proof-family route slice is [30 - vendor_procurement_quality_reviewed_route_2026-05-12.md](./30%20-%20vendor_procurement_quality_reviewed_route_2026-05-12.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: @@ -172,13 +176,13 @@ Current honest status: - pre-multidomain readiness: `~90%` - bounded-autonomy foundation readiness: `~89%` - open-world bounded-autonomy readiness: `~87%` -- active Open-World Bounded Autonomy Breadth implementation breadth: `~99%`, 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, the open-settlement quality bridge accepted by live semantic replay, selected-item profitability bridged by local semantic/runtime regression tests, contract-date debt age bridged locally, debt staleness-risk proxy bridged locally, debt due-date boundary arbitration bridged locally, inventory reserve/liquidation boundary arbitration bridged locally, supplier/procurement-quality boundary arbitration bridged locally, supplier concentration proxy bridged locally, document/account-section activity profile bridged locally, counterparty population/roles and contract usage profiles bridged locally, yearly operating-flow proxy bridged locally, earnings/best-year wording arbitration bridged locally, profit/margin wording boundary arbitration bridged locally, analyst synthesis added to business-overview answer drafting, company-period trading margin proxy bridged locally, inventory sales-to-stock proxy bridged locally, inventory staleness-risk proxy bridged locally, gap-specific answer shaping bridged locally, missing proof families recorded as runtime evidence ledger, exact accounting profit/margin promoted into a reviewed 90/91/99 route by phase93, and debt due-date aging promoted into a reviewed payment-term/open-balance route by phase94; confirmed vendor-risk/procurement-quality analysis and confirmed reserve/write-off/liquidation inventory evidence are still pending +- active Open-World Bounded Autonomy Breadth implementation breadth: `~99%`, 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, the open-settlement quality bridge accepted by live semantic replay, selected-item profitability bridged by local semantic/runtime regression tests, contract-date debt age bridged locally, debt staleness-risk proxy bridged locally, debt due-date boundary arbitration bridged locally, inventory reserve/liquidation boundary arbitration bridged locally, supplier/procurement-quality boundary arbitration bridged locally, supplier concentration proxy bridged locally, document/account-section activity profile bridged locally, counterparty population/roles and contract usage profiles bridged locally, yearly operating-flow proxy bridged locally, earnings/best-year wording arbitration bridged locally, profit/margin wording boundary arbitration bridged locally, analyst synthesis added to business-overview answer drafting, company-period trading margin proxy bridged locally, inventory sales-to-stock proxy bridged locally, inventory staleness-risk proxy bridged locally, gap-specific answer shaping bridged locally, missing proof families recorded as runtime evidence ledger, exact accounting profit/margin promoted into a reviewed 90/91/99 route by phase93, debt due-date aging promoted into a reviewed payment-term/open-balance route by phase94, and vendor/procurement quality promoted into reviewed procurement-concentration evidence by phase95; confirmed reserve/write-off/liquidation inventory evidence is still pending - active Open-World Bounded Autonomy Breadth accepted-module progress: `~99%`, because the EHMO-derived `Open-World Semantic Control Gate` critical subset accepts live at `21/21` after W5/W7 hardening; full closure is still held back for the fat manual GUI pack and remaining answer-shape residual review - 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 - Open-World Route Candidate Promotion progress: `100%` for the declared phase90 slice, with structured `route_candidate` runtime contract, artifact propagation, live semantic replay accepted at `5/5`, and accepted AGENT autorun persistence; broader autonomous route enablement remains the next active slice -- Route-Candidate-Driven Enablement Loop progress: `84%`, with deterministic repair-target grouping, Lead Codex handoff surfacing, local tooling tests, live phase91 canary acceptance, phase92 proof-family candidates accepted/saved as a user-runnable AGENT autorun, `accounting_profit_margin` promoted into reviewed 90/91/99 execution by phase93 live replay, and `debt_due_date_aging_quality` promoted into reviewed payment-term/open-balance execution by phase94 live replay; the remaining unresolved proof families are vendor/procurement quality and inventory reserve/liquidation +- Route-Candidate-Driven Enablement Loop progress: `92%`, with deterministic repair-target grouping, Lead Codex handoff surfacing, local tooling tests, live phase91 canary acceptance, phase92 proof-family candidates accepted/saved as a user-runnable AGENT autorun, `accounting_profit_margin` promoted into reviewed 90/91/99 execution by phase93 live replay, `debt_due_date_aging_quality` promoted into reviewed payment-term/open-balance execution by phase94 live replay, and `vendor_risk_procurement_quality` promoted into reviewed procurement-concentration evidence by phase95 live replay; the remaining inventory reserve/liquidation proof family is still pending - graph snapshot after latest rebuild: see `graphify-out/GRAPH_REPORT.md` - current regression-gate breakpoint: - the validated hot paths are no longer structurally broken; @@ -267,6 +271,7 @@ Latest live proof now includes: - proof-family enablement candidates accepted locally/live: targeted runtime/answer/turn-input/candidate tests passed `178/178` with `8` skipped; `address_truth_harness_phase92_proof_family_enablement_candidates_live5_20260510` accepted `6/6`, proving exact profit/margin, debt due-date aging, vendor-risk/procurement-quality, and reserve/liquidation asks remain bounded while their missing reviewed proof families become concrete route-candidate enablement targets; the accepted autorun is `AGENT | Phase 92 proof-family enablement candidates` (`gen-ag05101045-374169`). - accounting profit/margin reviewed route accepted locally/live: targeted runtime/answer/turn-input/candidate/intent tests passed `194/194` with `8` skipped; targeted VAT tax-period regression passed; `address_truth_harness_phase93_accounting_profit_margin_reviewed_route_live3_20260510` accepted `6/6`, proving 90/91/99 accounting result, short profit/loss follow-up continuity, VAT continuity, value-flow canary, and inventory reserve boundary canary together; the accepted autorun is `AGENT | Phase 93 accounting profit-margin reviewed route` (`gen-ag05101213-596d99`). - debt due-date aging reviewed route accepted locally/live: transition policy passed `38/38`, turn-input adapter passed `103/103` with `7` skipped, executor/answer/candidate/runtime bridge passed `113/113` with `1` skipped, build passed; `phase94_debt_due_date_aging_reviewed_route_live4` accepted `7/7`, proving payment-term/open-balance checked-negative overdue answers, short due-date boundary follow-up continuity, profit/margin/VAT/value-flow canaries, and reserve/vendor boundary safety together; the accepted autorun is `AGENT | Phase 94 debt due-date aging reviewed route` (`gen-ag05101319-c04f79`). +- vendor/procurement quality reviewed route accepted locally/live: executor/runtime bridge/answer/candidate tests passed `118/118` with `1` skipped, build passed; `phase95_vendor_procurement_quality_reviewed_route_live2` accepted `7/7`; `vendor_risk_procurement_quality` now derives reviewed procurement-concentration evidence from confirmed outgoing payment rows, separates bank-like outgoing leaders from ordinary supplier dependency, removes the proof family from `missing_proof_families` when this reviewed evidence exists, and can promote `vendor_risk_procurement_boundary` route candidates to `ready_for_reviewed_execution`; the accepted autorun is `AGENT | Phase 95 vendor/procurement quality reviewed route` (`gen-ag05121357-9ea5d6`). Current architectural reading: @@ -344,6 +349,8 @@ Read in this order: 27. `26 - route_candidate_driven_enablement_loop_2026-05-10.md` 28. `27 - proof_family_enablement_candidates_2026-05-10.md` 29. `28 - accounting_profit_margin_reviewed_route_2026-05-10.md` +30. `29 - debt_due_date_aging_reviewed_route_2026-05-10.md` +31. `30 - vendor_procurement_quality_reviewed_route_2026-05-12.md` ## Planning Rules diff --git a/docs/orchestration/address_truth_harness_phase95_vendor_procurement_quality_reviewed_route.json b/docs/orchestration/address_truth_harness_phase95_vendor_procurement_quality_reviewed_route.json new file mode 100644 index 0000000..a146d91 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase95_vendor_procurement_quality_reviewed_route.json @@ -0,0 +1,249 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase95_vendor_procurement_quality_reviewed_route", + "domain": "address_phase95_vendor_procurement_quality_reviewed_route", + "title": "Phase 95 vendor/procurement quality reviewed route", + "description": "Focused semantic replay for promoting vendor_risk_procurement_quality from proxy-only enablement to reviewed procurement-concentration evidence while preserving debt, profit, VAT, value-flow, and inventory reserve canaries.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_vendor_procurement_concentration_ready", + "title": "Vendor/procurement boundary uses reviewed outgoing concentration evidence", + "question": "по ООО Альтернатива Плюс за 2020 есть ли риск, что мы зависим от одного поставщика?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "expected_route_candidate_status": "ready_for_reviewed_execution", + "expected_route_candidate_executable_now": true, + "required_answer_patterns_all": [ + "(?i)поставщик|vendor|supplier|закуп|procurement", + "(?i)концентрац|зависим|исходящ|получател", + "(?i)надежност|качество|назначени|не доказ|не подтвержд" + ], + "forbidden_answer_patterns": [ + "(?i)точно подтверждаю.*надежност", + "(?i)точно подтверждаю.*качество", + "(?i)route_candidate", + "(?i)query_movements", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "vendor_risk_procurement_boundary", + "vendor_risk_procurement_quality", + "ready_for_reviewed_execution" + ] + }, + { + "step_id": "step_02_short_vendor_followup_keeps_bank_boundary", + "title": "Short follow-up keeps bank-like recipient boundary", + "question": "а банк из этого ответа считать обычным поставщиком?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "expected_route_candidate_status": "ready_for_reviewed_execution", + "expected_route_candidate_executable_now": true, + "required_answer_patterns_all": [ + "(?i)банк|финансов", + "(?i)не.*обычн.*поставщик|не.*поставщик|отдельн", + "(?i)назначени.*платеж|договор|не доказ|не подтвержд" + ], + "forbidden_answer_patterns": [ + "(?i)уточните организац", + "(?i)какую компанию", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "context_carryover", + "vendor_risk_procurement_boundary", + "financial_institution_boundary" + ] + }, + { + "step_id": "step_03_debt_due_date_canary_still_reviewed", + "title": "Debt due-date canary still uses reviewed payment-term evidence", + "question": "по этой же компании на конец 2020 можно точно понять, какая дебиторка просрочена?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "expected_route_candidate_status": "ready_for_reviewed_execution", + "expected_route_candidate_executable_now": true, + "required_answer_patterns_all": [ + "(?i)дебитор|долг|открыт.*расчет|остат", + "(?i)2020|2020-12-31|конец 2020", + "(?i)срок.*оплат|due[- ]?date|просроч", + "(?i)не установлен|не подтвержд|не доказ|нет подтвержденной просроч" + ], + "forbidden_answer_patterns": [ + "(?i)точно подтверждаю.*просроч", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "debt_due_date_boundary", + "canary" + ] + }, + { + "step_id": "step_04_profit_margin_canary_still_reviewed", + "title": "Profit/margin canary still uses accounting result route", + "question": "а чистая прибыль и маржа за 2020 какие?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "expected_route_candidate_status": "ready_for_reviewed_execution", + "expected_route_candidate_executable_now": true, + "required_answer_patterns_all": [ + "(?i)90/91/99|90\\.01|99", + "(?i)учетн|финрезульт|прибыл|убыт", + "(?i)марж|рентаб", + "(?i)2020" + ], + "forbidden_answer_patterns": [ + "(?i)только bounded operating-flow/trading-margin proxy", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "profit_margin_boundary", + "accounting_profit_margin", + "canary" + ] + }, + { + "step_id": "step_05_vat_canary_still_answers", + "title": "VAT continuity canary still answers from reviewed tax route", + "question": "НДС за 2020 по ООО Альтернатива Плюс какой?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)НДС|VAT|налог", + "(?i)2020", + "(?i)продаж|покуп|к уплат|к возмещ|зачет", + "(?i)подтвержд|проверенн|1С" + ], + "forbidden_answer_patterns": [ + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "vat_continuity", + "canary" + ] + }, + { + "step_id": "step_06_value_flow_ranking_context_still_works", + "title": "Value-flow ranking still uses organization context", + "question": "а кто принес больше всего денег за 2020?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "value_flow_ranking", + "expected_catalog_selected_matches_top": true, + "expected_route_candidate_status": "ready_for_reviewed_execution", + "expected_route_candidate_executable_now": true, + "required_answer_patterns_all": [ + "(?i)2020", + "(?i)контрагент|клиент|покупател", + "(?i)деньг|поступ|выруч|руб", + "(?i)подтвержд|проверенн|найден" + ], + "forbidden_answer_patterns": [ + "(?i)уточните организац", + "(?i)какую компанию", + "(?i)route_candidate", + "(?i)query_movements", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "value_flow_ranking", + "context_carryover", + "canary" + ] + }, + { + "step_id": "step_07_inventory_reserve_boundary_still_needs_route", + "title": "Inventory reserve boundary remains honest and bounded", + "question": "по ООО Альтернатива Плюс на конец 2020 можно точно подтвердить резерв под неликвиды на складе?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage", + "no_grounded_answer" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "expected_route_candidate_status": "needs_route_enablement", + "expected_route_candidate_executable_now": false, + "required_answer_patterns_all": [ + "(?i)резерв|неликвид|склад|товар", + "(?i)не подтвержд|не доказ|нельзя точно|нет точн", + "(?i)списан|ликвидац|учетн|провер" + ], + "forbidden_answer_patterns": [ + "(?i)точно подтверждаю", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "inventory_reserve_boundary", + "missing_proof_families", + "canary" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 72ef097..0f950fb 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1758,6 +1758,10 @@ function resolveUnicodeAddressIntentBridge(text) { if (!hasContractCue && hasCustomerConcentrationCue) { return unicodeBridgeResolution("customer_revenue_and_payments", "high", "unicode_customer_concentration_bridge_signal_detected"); } + const hasTopYearRevenueRankingCue = /(?:(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u0430\u044f|what|which)[\s\S]{0,80}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,80}(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,60}(?:\u0433\u043e\u0434|year)|(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,60}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,60}(?:\u0433\u043e\u0434|year))/iu.test(normalized); + if (!hasContractCue && (hasTopYearRevenueRankingCue || hasCustomerRevenueRankingBridgeSignal(normalized))) { + return unicodeBridgeResolution("customer_revenue_and_payments", "high", "unicode_customer_revenue_ranking_bridge_signal_detected"); + } if (hasOrganizationLevelEarningsOverviewBridgeSignal(normalized)) { return unicodeBridgeResolution("unknown", "high", "unicode_business_overview_earnings_deferred_to_discovery"); } @@ -2021,14 +2025,52 @@ function resolveUnicodeAddressIntentBridge(text) { } return null; } +function resolveDirectDebtSnapshotIntent(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return null; + } + if (/(?:ндс|vat)/iu.test(normalized)) { + return null; + } + const hasSnapshotCue = /(?:кто|сколько|есть\s+ли|по\s+состоянию|на\s+сегодня|на\s+дату|срез|остаток|сальдо|баланс|на\s+(?:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*(?:\s+(?:19|20)\d{2})?|на\s+(?:19|20)\d{2}|as\s+of|today|current|balance)/iu.test(normalized); + const hasReceivablesCue = /(?:кто\s+(?:является\s+)?дебитором|дебитор(?:[а-яё]{0,8})?|дебиторск(?:[а-яё]{0,8})?|кто\s+нам\s+долж(?:ен|ны|на|но)?|нам\s+(?:кто-то\s+|кто\s+)?долж(?:ен|ны|на|но)?|нам\s+торч(?:ат|ит|ишь|у|али)?|к\s+получению|к\s+взысканию|who\s+owes\s+us|receivables?|accounts\s+receivable)/iu.test(normalized); + const hasPayablesCue = /(?:кто\s+(?:является\s+)?кредитором|кредитор(?:[а-яё]{0,8})?|кому\s+мы\s+долж(?:ны|н[ао])?|мы\s+долж(?:ны|н[ао])?\s+кому|мы\s+долж(?:ны|н[ао])?|к\s+оплате|who\s+we\s+owe|payables?|accounts\s+payable)/iu.test(normalized); + if (hasReceivablesCue && !hasPayablesCue && hasSnapshotCue) { + return { + intent: "receivables_confirmed_as_of_date", + confidence: "high", + reasons: ["receivables_debt_lifecycle_signal_detected", "direct_debt_snapshot_signal_detected"] + }; + } + if (hasPayablesCue && !hasReceivablesCue && hasSnapshotCue) { + return { + intent: "payables_confirmed_as_of_date", + confidence: "high", + reasons: ["payables_debt_lifecycle_signal_detected", "direct_debt_snapshot_signal_detected"] + }; + } + return null; +} function resolveAddressIntent(userMessage) { const text = String(userMessage ?? "").trim().toLowerCase(); const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase(); const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text; const turnNoiseNormalizedBridgeText = bridgeText - .replace(/(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/giu, "$1\u043d\u0430\u043c") - .replace(/(^|[^\p{L}0-9_])\u043a\u0430\u043a\u0438\u0435\u043a(?=$|[^\p{L}0-9_])/giu, "$1\u043a\u0430\u043a\u0438\u0435"); + .replace(/(^|[^\p{L}0-9_])намс(?=$|[^\p{L}0-9_])/giu, "$1нам") + .replace(/(^|[^\p{L}0-9_])какиек(?=$|[^\p{L}0-9_])/giu, "$1какие"); const currentTurnBridgeText = turnNoiseNormalizedBridgeText !== bridgeText ? `${bridgeText} ${turnNoiseNormalizedBridgeText}` : bridgeText; + const directDebtSnapshotIntent = resolveDirectDebtSnapshotIntent(currentTurnBridgeText); + if (directDebtSnapshotIntent) { + const reasons = [...directDebtSnapshotIntent.reasons]; + if (currentTurnBridgeText !== bridgeText && !reasons.includes("current_turn_noise_normalized")) { + reasons.push("current_turn_noise_normalized"); + } + return { + ...directDebtSnapshotIntent, + reasons + }; + } const unicodeAddressIntent = resolveUnicodeAddressIntentBridge(currentTurnBridgeText); if (unicodeAddressIntent) { const reasons = [...unicodeAddressIntent.reasons]; diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 64727d4..92183e9 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -1822,10 +1822,10 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() : recipe.query_template === "receivables_confirmed_as_of_balance_profile" @@ -1840,10 +1840,10 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() : MOVEMENTS_QUERY_TEMPLATE diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 9742151..18dfafe 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -266,6 +266,17 @@ function normalizeQuestionText(value) { .replace(/\s+/g, " ") .trim(); } +function isReportStyleBusinessQuestion(userMessage) { + const text = normalizeQuestionText(userMessage); + return /(?:обзор|анализ|подроб|разверн|оцен|аудит|report|review|analysis)/iu.test(text); +} +function isDirectBalanceQuestion(userMessage) { + const text = normalizeQuestionText(userMessage); + if (!text || isReportStyleBusinessQuestion(text)) { + return false; + } + return /(?:кто|кому|сколько|какой|какая|какие|есть\s+ли|долж|дебитор|кредитор|payables?|receivables?|who|how\s+much)/iu.test(text); +} function hasInventoryPurchaseDateActionFocus(userMessage) { const text = normalizeQuestionText(userMessage); if (!text) { @@ -579,25 +590,42 @@ function extractRequestedYearFromQuestion(userMessage) { return 2000 + shortYear; } function extractCounterpartyName(row) { - const skipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu; - for (const token of row.analytics) { - const normalized = String(token ?? "").trim(); + const isCounterpartyLikeToken = (value, skipPattern) => { + const normalized = String(value ?? "").trim(); if (!normalized) { - continue; + return null; } if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { - continue; + return null; } if (/^\d+(?:[./-]\d+)*$/.test(normalized)) { - continue; + return null; } if (!/[a-zа-я]/iu.test(normalized)) { - continue; + return null; } - if (skipTokenPattern.test(normalized)) { - continue; + if (skipPattern.test(normalized)) { + return null; } return normalized; + }; + const hardSkipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|касса|расчетн|проводк|movement|invoice|payment)/iu; + const skipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu; + const directCounterparty = isCounterpartyLikeToken(row.counterparty, hardSkipTokenPattern); + if (directCounterparty) { + return directCounterparty; + } + if (/остатки\s+на\s+дату/iu.test(row.registrator)) { + const balancePrimaryCounterparty = isCounterpartyLikeToken(row.analytics[0], hardSkipTokenPattern); + if (balancePrimaryCounterparty) { + return balancePrimaryCounterparty; + } + } + for (const token of row.analytics) { + const normalized = isCounterpartyLikeToken(token, skipTokenPattern); + if (normalized) { + return normalized; + } } for (const token of row.analytics) { const normalized = String(token ?? "").trim(); @@ -1151,6 +1179,16 @@ function hasReceivablesSectionPrefix(account) { const section = extractAccountSectionCode(account); return section === "62" || section === "76"; } +function normalizeSettlementAccount(value) { + const normalized = String(value ?? "") + .trim() + .replace(",", "."); + return normalized || null; +} +function extractSettlementOrganizationName(row) { + const direct = String(row.organization ?? "").trim(); + return direct || null; +} function resolvePayablesAsOfDate(options) { const explicit = normalizeIsoDateOnly(options.asOfDate); if (explicit) { @@ -1430,6 +1468,211 @@ function buildReceivablesConfirmedBalanceAggregate(rows, asOfDate) { return left.name.localeCompare(right.name); }); } +function buildConfirmedDebtBalanceSnapshot(rows, asOfDate, hasRelevantSectionPrefix, positiveSide) { + const bySettlementKey = new Map(); + const asOfTimestamp = toUtcDayTimestamp(asOfDate); + for (const row of rows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const rowTimestamp = toUtcDayTimestamp(row.period); + if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) { + continue; + } + const amount = row.amount; + if (typeof amount !== "number" || !Number.isFinite(amount)) { + continue; + } + const absAmount = Math.abs(amount); + const debitAccount = normalizeSettlementAccount(row.account_dt); + const creditAccount = normalizeSettlementAccount(row.account_kt); + const contributions = []; + if (debitAccount && hasRelevantSectionPrefix(debitAccount)) { + contributions.push({ side: "debit", account: debitAccount }); + } + if (creditAccount && hasRelevantSectionPrefix(creditAccount)) { + contributions.push({ side: "credit", account: creditAccount }); + } + if (contributions.length === 0) { + continue; + } + const contract = extractSettlementBalanceAnalyticKey(row, name); + const organization = extractSettlementOrganizationName(row); + const classified = classifyPayablesLiabilityCategory(row, name); + const sourceRefs = extractPayablesSourceRefs(row, name, contract); + for (const contribution of contributions) { + const key = [ + normalizeEntityToken(organization), + normalizeEntityToken(contribution.account), + normalizeEntityToken(name), + normalizeEntityToken(contract) + ].join("|"); + const current = bySettlementKey.get(key); + if (!current) { + bySettlementKey.set(key, { + name, + account: contribution.account, + contract, + organization, + debitAmount: contribution.side === "debit" ? absAmount : 0, + creditAmount: contribution.side === "credit" ? absAmount : 0, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + categoryScores: { + supplier_or_contractor: classified.scores.supplier_or_contractor, + bank_or_credit: classified.scores.bank_or_credit, + tax_or_state: classified.scores.tax_or_state, + other: classified.scores.other + }, + reasons: new Set(classified.reasons), + contracts: new Set(contract ? [contract] : []), + documents: new Set(row.registrator ? [row.registrator] : []), + sourceRefs: new Set(sourceRefs) + }); + continue; + } + if (contribution.side === "debit") { + current.debitAmount += absAmount; + } + else { + current.creditAmount += absAmount; + } + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor; + current.categoryScores.bank_or_credit += classified.scores.bank_or_credit; + current.categoryScores.tax_or_state += classified.scores.tax_or_state; + current.categoryScores.other += classified.scores.other; + for (const reason of classified.reasons) { + current.reasons.add(reason); + } + if (contract) { + current.contracts.add(contract); + } + if (row.registrator) { + current.documents.add(row.registrator); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + } + const byCounterparty = new Map(); + const mirrorGroups = []; + let mirroredOffsetAmount = 0; + for (const group of bySettlementKey.values()) { + const offsetAmount = Math.min(group.debitAmount, group.creditAmount); + const netDebitMinusCredit = group.debitAmount - group.creditAmount; + if (offsetAmount > 0.005) { + mirroredOffsetAmount += offsetAmount; + mirrorGroups.push({ + name: group.name, + account: group.account, + contract: group.contract, + organization: group.organization, + debitAmount: group.debitAmount, + creditAmount: group.creditAmount, + offsetAmount, + netAmount: netDebitMinusCredit, + operations: group.operations, + sourceRefs: Array.from(group.sourceRefs).slice(0, 3) + }); + } + const sideNetAmount = positiveSide === "credit" ? group.creditAmount - group.debitAmount : group.debitAmount - group.creditAmount; + if (sideNetAmount <= 0.005) { + continue; + } + const current = byCounterparty.get(group.name); + if (!current) { + byCounterparty.set(group.name, { + outstandingAmount: sideNetAmount, + operations: group.operations, + firstPeriod: group.firstPeriod, + lastPeriod: group.lastPeriod, + categoryScores: { + supplier_or_contractor: group.categoryScores.supplier_or_contractor, + bank_or_credit: group.categoryScores.bank_or_credit, + tax_or_state: group.categoryScores.tax_or_state, + other: group.categoryScores.other + }, + reasons: new Set(group.reasons), + contracts: new Set(group.contracts), + documents: new Set(group.documents), + sourceRefs: new Set(group.sourceRefs) + }); + continue; + } + current.outstandingAmount += sideNetAmount; + current.operations += group.operations; + if ((group.firstPeriod ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = group.firstPeriod; + } + if ((group.lastPeriod ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = group.lastPeriod; + } + current.categoryScores.supplier_or_contractor += group.categoryScores.supplier_or_contractor; + current.categoryScores.bank_or_credit += group.categoryScores.bank_or_credit; + current.categoryScores.tax_or_state += group.categoryScores.tax_or_state; + current.categoryScores.other += group.categoryScores.other; + for (const reason of group.reasons) { + current.reasons.add(reason); + } + for (const contract of group.contracts) { + current.contracts.add(contract); + } + for (const document of group.documents) { + current.documents.add(document); + } + for (const ref of group.sourceRefs) { + current.sourceRefs.add(ref); + } + } + return { + balances: Array.from(byCounterparty.entries()) + .map(([name, item]) => ({ + name, + outstandingAmount: item.outstandingAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + category: resolvePayablesLiabilityCategory(item.categoryScores), + categoryReasons: Array.from(item.reasons).slice(0, 2), + contracts: Array.from(item.contracts).slice(0, 2), + documents: Array.from(item.documents).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) + })) + .filter((item) => item.outstandingAmount > 0.005) + .sort((left, right) => { + if (right.outstandingAmount !== left.outstandingAmount) { + return right.outstandingAmount - left.outstandingAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.name.localeCompare(right.name); + }), + mirrorGroups: mirrorGroups.sort((left, right) => { + if (right.offsetAmount !== left.offsetAmount) { + return right.offsetAmount - left.offsetAmount; + } + return left.name.localeCompare(right.name); + }), + mirroredOffsetAmount + }; +} +function buildPayablesConfirmedBalanceSnapshot(rows, asOfDate) { + return buildConfirmedDebtBalanceSnapshot(rows, asOfDate, hasPayablesSectionPrefix, "credit"); +} +function buildReceivablesConfirmedBalanceSnapshot(rows, asOfDate) { + return buildConfirmedDebtBalanceSnapshot(rows, asOfDate, hasReceivablesSectionPrefix, "debit"); +} function buildCounterpartyRiskAggregate(rows) { const byCounterparty = new Map(); for (const row of rows) { @@ -1603,6 +1846,45 @@ function extractContractName(row) { } return null; } +function extractSettlementBalanceAnalyticKey(row, counterparty) { + const counterpartyToken = normalizeSettlementComparableToken(counterparty); + const organizationToken = normalizeSettlementComparableToken(extractSettlementOrganizationName(row)); + const contract = extractContractName(row); + if (contract) { + const contractToken = normalizeSettlementComparableToken(contract); + if (contractToken && + contractToken !== counterpartyToken && + contractToken !== organizationToken && + !(Boolean(organizationToken) && contractToken.includes(organizationToken)) && + !/^организац/.test(contractToken)) { + return contract; + } + } + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + const normalizedToken = normalizeSettlementComparableToken(normalized); + if (!normalized || !normalizedToken) { + continue; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) { + continue; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized) || /^\d+(?:[.,]\d+)?$/.test(normalized)) { + continue; + } + if (/^\d{2}(?:\.\d{1,2})?$/.test(normalized)) { + continue; + } + if (normalizedToken === counterpartyToken || + normalizedToken === organizationToken || + (Boolean(organizationToken) && normalizedToken.includes(organizationToken)) || + /^организац/.test(normalizedToken)) { + continue; + } + return normalized; + } + return null; +} function normalizeEntityToken(value) { return String(value ?? "") .toLowerCase() @@ -1610,6 +1892,12 @@ function normalizeEntityToken(value) { .replace(/\s+/g, " ") .trim(); } +function normalizeSettlementComparableToken(value) { + return normalizeEntityToken(value) + .replace(/[^\p{L}0-9]+/giu, " ") + .replace(/\s+/g, " ") + .trim(); +} function extractPayablesSourceRefs(row, counterparty, contract) { const refs = new Set(); const counterpartyToken = normalizeEntityToken(counterparty); @@ -1656,6 +1944,41 @@ function formatPayablesEvidenceSuffix(item) { } return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; } +function formatDebtMirrorGroupLine(item) { + const details = [ + item.account ? `счет ${item.account}` : null, + item.contract ? `договор/аналитика: ${item.contract}` : null, + item.organization ? `организация: ${item.organization}` : null + ].filter((part) => Boolean(part)); + const netText = Math.abs(item.netAmount) <= 0.005 + ? "чисто: 0 ₽" + : item.netAmount > 0 + ? `чисто к получению: ${formatMoneyRub(item.netAmount)}` + : `чисто к оплате: ${formatMoneyRub(Math.abs(item.netAmount))}`; + return `${item.name}${details.length > 0 ? ` (${details.join(", ")})` : ""}: дебет ${formatMoneyRub(item.debitAmount)} / кредит ${formatMoneyRub(item.creditAmount)}, ${netText}.`; +} +function debtMirrorCleanScopeLabel(kind) { + return kind === "payables" ? "чистый долг к оплате" : "чистую дебиторку к получению"; +} +function appendDebtMirrorCompactDisclosure(lines, snapshot, kind) { + if (snapshot.mirroredOffsetAmount <= 0.005) { + return; + } + lines.push(`Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.`); + const leadingMirror = snapshot.mirrorGroups[0] ?? null; + if (leadingMirror) { + lines.push(`Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`); + } +} +function appendDebtMirrorDisclosure(lines, snapshot, kind) { + if (snapshot.mirroredOffsetAmount <= 0.005) { + return; + } + lines.push(""); + lines.push("Встречные остатки к сверке"); + lines.push(`- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она исключена из ${debtMirrorCleanScopeLabel(kind)}.`); + lines.push(...snapshot.mirrorGroups.slice(0, 3).map((item, index) => `${index + 1}. ${formatDebtMirrorGroupLine(item)}`)); +} function deriveOperationalYearWindow(yearDocs, yearOps) { const docsSeries = [...yearDocs].sort((a, b) => a.year - b.year); const fallbackSeries = [...yearOps].sort((a, b) => a.year - b.year); @@ -2955,7 +3278,8 @@ function composeFactualReplyBody(intent, rows, options = {}) { } if (intent === "payables_confirmed_as_of_date") { const payablesAsOfDate = resolvePayablesAsOfDate(options); - const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate); + const balanceSnapshot = buildPayablesConfirmedBalanceSnapshot(rows, payablesAsOfDate); + const confirmedBalances = balanceSnapshot.balances; const asOfDate = normalizeIsoDateOnly(options.asOfDate); const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodTo = normalizeIsoDateOnly(options.periodTo); @@ -2970,6 +3294,35 @@ function composeFactualReplyBody(intent, rows, options = {}) { acc[item.category] += 1; return acc; }, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); + if (isDirectBalanceQuestion(options.userMessage)) { + const leading = confirmedBalances[0] ?? null; + const compactLines = leading + ? [ + `Коротко: на ${formatDateRu(payablesAsOfDate)} мы должны ${formatMoneyRub(totalOutstandingAmount)}; крупнейшая позиция — ${leading.name} (${formatMoneyRub(leading.outstandingAmount)}).`, + "Крупнейшие позиции к оплате:" + ] + : [`Коротко: на ${formatDateRu(payablesAsOfDate)} подтвержденных обязательств к оплате не найдено.`]; + if (leading) { + compactLines.push(...confirmedBalances.slice(0, 5).map((item, index) => { + const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : ""; + return `${index + 1}. ${item.name} — ${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`; + })); + if (confirmedBalances.length > 5) { + compactLines.push(`Показаны первые 5 из ${formatNumberWithDots(confirmedBalances.length)} подтвержденных позиций.`); + } + } + appendDebtMirrorCompactDisclosure(compactLines, balanceSnapshot, "payables"); + compactLines.push(`Основа: подтвержденный остаток по счетам 60/76, срез ${formatDateRu(payablesAsOfDate)}.`); + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: joinLines(compactLines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } const lines = [ `Коротко: подтвержденный долг к оплате на ${formatDateRu(payablesAsOfDate)} — ${formatMoneyRub(totalOutstandingAmount)}.`, "Это подтвержденный срез обязательств к оплате по точному остатку." @@ -2988,6 +3341,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { lines.push("Сводка"); lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${formatNumberWithDots(confirmedBalances.length)}.`); + appendDebtMirrorDisclosure(lines, balanceSnapshot, "payables"); lines.push(""); lines.push("Категории обязательств"); lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}.`); @@ -3020,7 +3374,8 @@ function composeFactualReplyBody(intent, rows, options = {}) { } if (intent === "receivables_confirmed_as_of_date") { const receivablesAsOfDate = resolveReceivablesAsOfDate(options); - const confirmedBalances = buildReceivablesConfirmedBalanceAggregate(rows, receivablesAsOfDate); + const balanceSnapshot = buildReceivablesConfirmedBalanceSnapshot(rows, receivablesAsOfDate); + const confirmedBalances = balanceSnapshot.balances; const asOfDate = normalizeIsoDateOnly(options.asOfDate); const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodTo = normalizeIsoDateOnly(options.periodTo); @@ -3035,6 +3390,35 @@ function composeFactualReplyBody(intent, rows, options = {}) { acc[item.category] += 1; return acc; }, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }); + if (isDirectBalanceQuestion(options.userMessage)) { + const leading = confirmedBalances[0] ?? null; + const compactLines = leading + ? [ + `Коротко: на ${formatDateRu(receivablesAsOfDate)} нам должны ${formatMoneyRub(totalOutstandingAmount)}; крупнейшая позиция — ${leading.name} (${formatMoneyRub(leading.outstandingAmount)}).`, + "Крупнейшие позиции к получению:" + ] + : [`Коротко: на ${formatDateRu(receivablesAsOfDate)} подтвержденной дебиторской задолженности не найдено.`]; + if (leading) { + compactLines.push(...confirmedBalances.slice(0, 5).map((item, index) => { + const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : ""; + return `${index + 1}. ${item.name} — ${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`; + })); + if (confirmedBalances.length > 5) { + compactLines.push(`Показаны первые 5 из ${formatNumberWithDots(confirmedBalances.length)} подтвержденных позиций.`); + } + } + appendDebtMirrorCompactDisclosure(compactLines, balanceSnapshot, "receivables"); + compactLines.push(`Основа: подтвержденный остаток по счетам 62/76, срез ${formatDateRu(receivablesAsOfDate)}.`); + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: joinLines(compactLines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } const lines = [ `Коротко: подтвержденная дебиторская задолженность на ${formatDateRu(receivablesAsOfDate)} — ${formatMoneyRub(totalOutstandingAmount)}.`, "Это подтвержденный срез дебиторской задолженности, а не эвристический shortlist." @@ -3053,6 +3437,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { lines.push("Сводка"); lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); lines.push(`- Контрагентов с подтвержденным остатком к получению: ${formatNumberWithDots(confirmedBalances.length)}.`); + appendDebtMirrorDisclosure(lines, balanceSnapshot, "receivables"); lines.push(""); lines.push("Категории дебиторской задолженности"); lines.push(`- ${receivablesCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}.`); @@ -3160,7 +3545,8 @@ function composeFactualReplyBody(intent, rows, options = {}) { return lines; }; if (options.requestedResultMode === "confirmed_balance") { - const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate); + const balanceSnapshot = buildPayablesConfirmedBalanceSnapshot(rows, payablesAsOfDate); + const confirmedBalances = balanceSnapshot.balances; if (confirmedBalances.length > 0) { const categoryCounts = confirmedBalances.reduce((acc, item) => { acc[item.category] += 1; @@ -3195,6 +3581,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { "Блок 5. Крупнейшие подтвержденные позиции к оплате (по сумме остатка):", ...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoneyRub(item.outstandingAmount)} | операций в срезе: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`) ]; + appendDebtMirrorDisclosure(lines, balanceSnapshot, "payables"); return { responseType: "FACTUAL_LIST", text: joinLines(lines), diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js index 1d3fabb..1893574 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -391,7 +391,11 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { /(?:какой|кто|which|who|какой|кто)/iu.test(normalizedQuestion) && /(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|прин[её]с|highest|most|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибол|РїСЂРёРЅ[её]СЃ)/iu.test(normalizedQuestion) && !/(?:\btop\b|топ|рейтинг|список|первые|покажи\s+топ|дай\s+топ|покаж\w*\s+топ|дай\s+топ)/iu.test(normalizedQuestion); - const effectiveLimit = asksSingleBestCounterparty ? 1 : limit; + const asksExplicitRankingList = /(?:\btop\b|топ|рейтинг|список|первые|покажи\s+(?:топ|список)|дай\s+(?:топ|список)|show\s+(?:top|list))/iu.test(normalizedQuestion); + const hasSingleBestCounterpartyCue = /(?:сам\p{L}*|больше\s+всего|наибольш|прин[её]с|определ\p{L}*|найд\p{L}*|highest|largest|most)/iu.test(normalizedQuestion) && + /(?:клиент|заказчик|покупател|контрагент|customer|client|counterparty|buyer)/iu.test(normalizedQuestion); + const semanticSingleBestCounterparty = focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList; + const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit; const byCounterparty = new Map(); const byYear = new Map(); const deals = []; diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index ddeb6a8..8e4f3e9 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -126,6 +126,14 @@ function hasExplicitLooseByAnchorToken(text) { return !pronounTokens.has(token) && !genericTokens.has(token); } const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([ + "мы", + "нам", + "нас", + "наш", + "наша", + "наше", + "наши", + "унас", "есть", "же", "что", @@ -1148,6 +1156,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { previousOrganization ?? (followupContext.previous_anchor_type === "organization" ? previousAnchorValue : null); const finalCounterparty = toNonEmptyString(merged.counterparty); + if (finalCounterparty && isLowQualityCounterpartyAnchor(finalCounterparty)) { + delete merged.counterparty; + if (!reasons.includes("counterparty_cleared_low_quality_followup_anchor")) { + reasons.push("counterparty_cleared_low_quality_followup_anchor"); + } + } if (shouldSuppressInventoryCounterpartyAlias(intent, finalCounterparty, finalOrganizationReference)) { delete merged.counterparty; if (!reasons.includes("counterparty_cleared_as_organization_scope_alias")) { diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 6943d2f..630fbb6 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -371,6 +371,14 @@ function isReferentialCounterpartyPlaceholder(value) { return false; } return new Set([ + "мы", + "нам", + "нас", + "наш", + "наша", + "наше", + "наши", + "унас", "он", "она", "оно", diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index c4a354a..d9823d9 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -592,6 +592,35 @@ function businessOverviewDebtDueDateAgingText(overview) { } return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`; } +function businessOverviewVendorProcurementQualityText(overview) { + const quality = overview.vendor_procurement_quality; + if (!quality) { + return null; + } + const period = quality.period_scope ?? "проверенное окно"; + const total = quality.total_outgoing_amount_human_ru; + const top = quality.top_outgoing_counterparty; + const topName = top?.axis_value ?? "получатель не распознан"; + const topShare = quality.top_outgoing_share_pct === null ? "" : `, около ${quality.top_outgoing_share_pct}%`; + const topAmount = top?.total_amount_human_ru ? ` (${top.total_amount_human_ru})` : ""; + const nonFinancial = quality.top_non_financial_supplier; + const nonFinancialShare = quality.top_non_financial_supplier_share_pct === null ? "" : `, около ${quality.top_non_financial_supplier_share_pct}%`; + const nonFinancialText = nonFinancial + ? ` Крупнейший небанковский получатель: ${nonFinancial.axis_value}${nonFinancialShare}${nonFinancial.total_amount_human_ru ? ` (${nonFinancial.total_amount_human_ru})` : ""}.` + : ""; + const contractText = quality.used_contracts === null + ? "" + : quality.total_contracts === null + ? ` Договорный профиль: используется ${quality.used_contracts} договоров.` + : ` Договорный профиль: используется ${quality.used_contracts}/${quality.total_contracts} договоров${quality.used_contract_share_pct === null ? "" : ` (${quality.used_contract_share_pct}%)`}.`; + if (quality.evidence_status === "financial_institution_leads_outgoing_cash") { + return `Проверенный procurement-concentration route за ${period}: крупнейший получатель исходящих денег ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}. По названию это банк/финансовая организация, поэтому зависимость от обычного поставщика этим не подтверждается.${nonFinancialText}${contractText} Надежность поставщиков, качество поставок, назначение каждого платежа и полная структура расходов этим маршрутом не доказаны.`; + } + if (quality.evidence_status === "reviewed_procurement_concentration") { + return `Проверенный procurement-concentration route за ${period}: крупнейший поставщик/получатель исходящих платежей ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}.${contractText} Это проверенный сигнал концентрации закупок/исходящих платежей, но не аудит надежности поставщика, качества поставок и полной структуры расходов.`; + } + return `Procurement-concentration route за ${period} отработал по исходящим платежам на ${total}, но надежной небанковской концентрации поставщика по найденным строкам не хватает.${contractText} Полный vendor-risk аудит не подтвержден.`; +} function headlineFor(mode, pilot) { const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || pilot.derived_value_flow?.aggregation_axis === "month"; @@ -632,6 +661,10 @@ function headlineFor(mode, pilot) { return `Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя; ${inventoryBasis}. Можно честно говорить только о необходимости отдельной проверки склада, списаний/резервов и ликвидационной стоимости, не превращая proxy в доказанный факт резерва.`; } if (isVendorRiskBoundaryTurn(pilot)) { + const vendorQualityText = businessOverviewVendorProcurementQualityText(overview); + if (vendorQualityText) { + return vendorQualityText; + } const supplierLeader = overview.top_suppliers?.[0] ?? null; const proxyLabel = isFinancialInstitutionBucket(supplierLeader) ? "outgoing cash concentration proxy" @@ -897,7 +930,12 @@ function buildMustNotClaim(pilot) { claims.push("Do not present business overview cash-flow spread as profit or margin."); claims.push("Do not present business overview yearly operating-flow breakdown as profit, financial result, or a complete annual P&L."); claims.push("Do not present business overview trading-margin proxy as clean profit, accounting financial result, or exact cost-of-sales margin."); - claims.push("Do not present business overview supplier concentration as vendor-risk audit, procurement quality, or full expense structure."); + if (pilot.derived_business_overview?.vendor_procurement_quality) { + claims.push("Do not present reviewed procurement concentration as supplier reliability, delivery quality, payment-purpose classification, or full expense structure."); + } + else { + claims.push("Do not present business overview supplier concentration as vendor-risk audit, procurement quality, or full expense structure."); + } claims.push("Do not present business overview document/account-section activity profile as process quality, accounting correctness, or completeness of all 1C activity."); claims.push("Do not present business overview counterparty or contract profile as CRM quality, counterparty due diligence, contract-risk audit, or legal completeness."); claims.push("Do not claim debt quality, VAT position, inventory health, or company health unless those contours were separately checked."); @@ -1279,6 +1317,10 @@ function derivedBusinessOverviewConfirmedLines(pilot) { if (outgoingLeaderLine) { lines.push(outgoingLeaderLine); } + const vendorQualityText = businessOverviewVendorProcurementQualityText(overview); + if (vendorQualityText) { + lines.push(vendorQualityText); + } if (overview.yearly_breakdown?.length) { lines.push(`Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(overview.yearly_breakdown.length)}.`); } @@ -1667,6 +1709,10 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { if (pilot.derived_business_overview?.top_suppliers?.length) { pushReason(reasonCodes, "answer_contains_business_overview_supplier_concentration"); } + if (pilot.derived_business_overview?.vendor_procurement_quality) { + pushReason(reasonCodes, "answer_contains_business_overview_vendor_procurement_quality"); + pushReason(reasonCodes, `answer_contains_business_overview_vendor_procurement_quality_${pilot.derived_business_overview.vendor_procurement_quality.evidence_status}`); + } if (pilot.derived_business_overview?.yearly_breakdown?.length) { pushReason(reasonCodes, "answer_contains_business_overview_yearly_operating_breakdown"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 65a0864..1a45803 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -3021,6 +3021,46 @@ function deriveBusinessOverviewInventoryStalenessRiskProxy(input) { inference_basis: "purchase_date_age_and_sales_to_stock_proxy_confirmed_1c_rows" }; } +function deriveBusinessOverviewVendorProcurementQuality(input) { + if (!input.rankedOutgoing || + input.rankedOutgoing.ranked_values.length <= 0 || + input.outgoing.rows_with_amount <= 0 || + input.outgoing.total_amount <= 0) { + return null; + } + const topOutgoingCounterparty = input.rankedOutgoing.ranked_values[0] ?? null; + const topNonFinancialSupplier = input.rankedOutgoing.ranked_values.find((item) => !(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(item.axis_value)) ?? null; + const financialInstitutionLeadsOutgoingCash = topOutgoingCounterparty + ? (0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(topOutgoingCounterparty.axis_value) + : false; + const evidenceStatus = !topNonFinancialSupplier + ? "insufficient_supplier_concentration_basis" + : financialInstitutionLeadsOutgoingCash + ? "financial_institution_leads_outgoing_cash" + : "reviewed_procurement_concentration"; + return { + period_scope: input.periodScope, + rows_with_amount: input.outgoing.rows_with_amount, + total_outgoing_amount: input.outgoing.total_amount, + total_outgoing_amount_human_ru: input.outgoing.total_amount_human_ru, + top_outgoing_counterparty: topOutgoingCounterparty, + top_outgoing_share_pct: topOutgoingCounterparty + ? percentageOfTotal(topOutgoingCounterparty.total_amount, input.outgoing.total_amount) + : null, + top_non_financial_supplier: topNonFinancialSupplier, + top_non_financial_supplier_share_pct: topNonFinancialSupplier + ? percentageOfTotal(topNonFinancialSupplier.total_amount, input.outgoing.total_amount) + : null, + financial_institution_leads_outgoing_cash: financialInstitutionLeadsOutgoingCash, + supplier_only_count: input.counterpartyProfile?.supplier_only_count ?? null, + mixed_role_count: input.counterpartyProfile?.mixed_role_count ?? null, + used_contracts: input.contractUsageProfile?.used_contracts ?? null, + total_contracts: input.contractUsageProfile?.total_contracts ?? null, + used_contract_share_pct: input.contractUsageProfile?.used_contract_share_pct ?? null, + evidence_status: evidenceStatus, + inference_basis: "supplier_payout_concentration_counterparty_contract_profile_confirmed_1c_rows" + }; +} function inventoryStalenessRiskBandRu(riskBand) { if (riskBand === "high") { return "высокая зона внимания"; @@ -3091,7 +3131,7 @@ function buildBusinessOverviewMissingProofFamilies(input) { must_not_claim: "confirmed_obsolete_stock_reserve_writeoff_or_liquidation_value" }); } - if (input.hasSupplierConcentrationSignal) { + if (input.hasSupplierConcentrationSignal && !input.vendorProcurementQuality) { pushUnique({ family: "vendor_risk_procurement_quality", current_status: "proxy_only_currently", @@ -3155,6 +3195,13 @@ function deriveBusinessOverview(input) { inventoryPosition, inventoryTurnoverProxy }); + const vendorProcurementQuality = deriveBusinessOverviewVendorProcurementQuality({ + rankedOutgoing, + outgoing, + counterpartyProfile, + contractUsageProfile, + periodScope: input.periodScope + }); const checkedSignalCount = [ incoming.rows_with_amount > 0, outgoing.rows_with_amount > 0, @@ -3171,7 +3218,8 @@ function deriveBusinessOverview(input) { Boolean(contractUsageProfile), Boolean(inventoryPosition), Boolean(inventoryTurnoverProxy), - Boolean(inventoryStalenessRiskProxy) + Boolean(inventoryStalenessRiskProxy), + Boolean(vendorProcurementQuality) ].filter(Boolean).length; if (checkedSignalCount <= 0) { return null; @@ -3202,6 +3250,7 @@ function deriveBusinessOverview(input) { inventoryPosition, inventoryTurnoverProxy, inventoryStalenessRiskProxy, + vendorProcurementQuality, hasSupplierConcentrationSignal: (rankedOutgoing?.ranked_values.length ?? 0) > 0 }); return { @@ -3229,6 +3278,7 @@ function deriveBusinessOverview(input) { document_activity_profile: documentActivityProfile, counterparty_profile: counterpartyProfile, contract_usage_profile: contractUsageProfile, + vendor_procurement_quality: vendorProcurementQuality, coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, checked_signal_count: checkedSignalCount, missing_signal_families: missingSignalFamilies, @@ -4641,6 +4691,10 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { if (derivedBusinessOverview.top_suppliers.length > 0) { pushReason(reasonCodes, "pilot_derived_business_overview_top_suppliers_from_confirmed_rows"); } + if (derivedBusinessOverview.vendor_procurement_quality) { + pushReason(reasonCodes, "pilot_derived_business_overview_vendor_procurement_quality_from_confirmed_rows"); + pushReason(reasonCodes, `pilot_derived_business_overview_vendor_procurement_quality_${derivedBusinessOverview.vendor_procurement_quality.evidence_status}`); + } if (derivedBusinessOverview.yearly_breakdown.length > 0) { pushReason(reasonCodes, "pilot_derived_business_overview_yearly_operating_breakdown_from_confirmed_rows"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index b012756..e08fdb9 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -601,7 +601,7 @@ function recipeFor(input) { extraReasons: primitiveSelection.reasonCodes }); } - if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) { + if (dataNeedGraph?.ranking_need) { pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -621,7 +621,10 @@ function recipeFor(input) { reason: dataNeedGraph.ranking_need === "bottom_asc" ? "planner_selected_bottom_ranked_value_flow_from_data_need_graph" : "planner_selected_top_ranked_value_flow_from_data_need_graph", - extraReasons: primitiveSelection.reasonCodes + extraReasons: [ + ...primitiveSelection.reasonCodes, + ...(hasSubjectCandidates(dataNeedGraph) ? ["planner_kept_ranking_over_subject_scope_candidates"] : []) + ] }); } if (openScopeTotalWithoutSubject) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index f21995a..c8ae7af 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -773,6 +773,47 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; } if (vendorRiskBoundary) { + const vendorProcurementQuality = toRecordObject(overview.vendor_procurement_quality); + if (vendorProcurementQuality) { + const status = toNonEmptyString(vendorProcurementQuality.evidence_status); + const totalOutgoing = moneyText(vendorProcurementQuality.total_outgoing_amount_human_ru); + const topOutgoingRecord = toRecordObject(vendorProcurementQuality.top_outgoing_counterparty); + const topOutgoingName = toNonEmptyString(topOutgoingRecord?.axis_value); + const topOutgoingAmount = moneyText(topOutgoingRecord?.total_amount_human_ru); + const topOutgoingShare = typeof vendorProcurementQuality.top_outgoing_share_pct === "number" && + Number.isFinite(vendorProcurementQuality.top_outgoing_share_pct) + ? `${vendorProcurementQuality.top_outgoing_share_pct}%` + : null; + const nonFinancialRecord = toRecordObject(vendorProcurementQuality.top_non_financial_supplier); + const nonFinancialName = toNonEmptyString(nonFinancialRecord?.axis_value); + const nonFinancialAmount = moneyText(nonFinancialRecord?.total_amount_human_ru); + const nonFinancialShare = typeof vendorProcurementQuality.top_non_financial_supplier_share_pct === "number" && + Number.isFinite(vendorProcurementQuality.top_non_financial_supplier_share_pct) + ? `${vendorProcurementQuality.top_non_financial_supplier_share_pct}%` + : null; + const periodScope = toNonEmptyString(vendorProcurementQuality.period_scope) ?? period; + const totalText = totalOutgoing ? `; всего исходящих платежей в проверенном срезе ${totalOutgoing}` : ""; + if (status === "financial_institution_leads_outgoing_cash") { + lines.push(`Коротко: проверенный procurement-concentration route за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`); + if (nonFinancialName) { + lines.push(`Крупнейший небанковский получатель исходящих денег: ${nonFinancialName}${nonFinancialShare ? `, около ${nonFinancialShare}` : ""}${nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}. Это уже сигнал закупочной/исходящей концентрации, но не аудит надежности поставщика.`); + } + } + else if (status === "reviewed_procurement_concentration") { + lines.push(`Коротко: проверенный procurement-concentration route за ${periodScope} нашел основную зависимость исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.`); + } + else { + lines.push(`Коротко: procurement-concentration route за ${periodScope} отработал, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.`); + } + const contractText = typeof vendorProcurementQuality.used_contracts === "number" && Number.isFinite(vendorProcurementQuality.used_contracts) + ? typeof vendorProcurementQuality.total_contracts === "number" && Number.isFinite(vendorProcurementQuality.total_contracts) + ? ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts}/${vendorProcurementQuality.total_contracts} договоров${typeof vendorProcurementQuality.used_contract_share_pct === "number" && Number.isFinite(vendorProcurementQuality.used_contract_share_pct) ? ` (${vendorProcurementQuality.used_contract_share_pct}%)` : ""}.` + : ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts} договоров.` + : ""; + lines.push(`Что не доказано этим маршрутом: надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полная структура всех расходов.${contractText}`); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } const supplierBasis = topSupplier ? topSupplierLooksFinancial ? `крупнейший получатель исходящих денег: ${topSupplier}; по названию это банк/финансовая организация, поэтому это не доказанная зависимость от обычного поставщика${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}` diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 6e80bf2..f102680 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -167,6 +167,21 @@ function isMetadataDiscoveryTurn(entryPoint) { toNonEmptyString(pilot?.pilot_scope) === "metadata_inspection_v1" || reasonCodes.some((reason) => toNonEmptyString(reason) === "mcp_discovery_metadata_signal_detected")); } +function isExplicitMetadataDiscoveryTurn(entryPoint) { + const turnInput = toRecordObject(entryPoint?.turn_input); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const graph = readDiscoveryDataNeedGraph(entryPoint); + const reasonCodes = [ + ...(Array.isArray(entryPoint?.reason_codes) ? entryPoint.reason_codes : []), + ...(Array.isArray(turnInput?.reason_codes) ? turnInput.reason_codes : []) + ]; + const decompositionCandidates = Array.isArray(graph?.decomposition_candidates) ? graph.decomposition_candidates : []; + return Boolean(toNonEmptyString(turnMeaning?.asked_domain_family) === "metadata" || + toNonEmptyString(turnMeaning?.unsupported_but_understood_family) === "1c_metadata_surface" || + toNonEmptyString(graph?.business_fact_family) === "schema_surface" || + decompositionCandidates.some((candidate) => toNonEmptyString(candidate) === "inspect_metadata_surface") || + reasonCodes.some((reason) => toNonEmptyString(reason) === "mcp_discovery_metadata_signal_detected")); +} function isInventoryExactAddressIntent(intent) { return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(String(intent ?? "")); } @@ -180,6 +195,9 @@ function hasMetadataDiscoveryPriority(input, entryPoint) { if (!isMetadataDiscoveryTurn(entryPoint)) { return false; } + if (!isExplicitMetadataDiscoveryTurn(entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); return !isInventoryExactAddressIntent(detectedIntent); } @@ -250,6 +268,55 @@ function readStringArray(value) { ? value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)) : []; } +function hasConfirmedAddressExecution(input) { + const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); + const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const bindingStatus = toNonEmptyString(input.addressRuntimeMeta?.capability_binding_status); + const bindingViolations = readStringArray(input.addressRuntimeMeta?.capability_binding_violations); + return Boolean(mcpCallStatus === "matched_non_empty" && + truthMode === "confirmed" && + selectedRecipe?.startsWith("address_") && + (bindingStatus === "bound" || bindingStatus === "bound_with_limits") && + bindingViolations.length === 0); +} +function hasStaleMetadataDiscoveryFallbackAgainstExactAddressReply(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (!isMetadataDiscoveryTurn(entryPoint) || isExplicitMetadataDiscoveryTurn(entryPoint)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + return Boolean(detectedIntent && + hasConfirmedAddressExecution(input) && + isDetectedIntentAlignedWithTurnMeaning(detectedIntent, readDiscoveryTurnMeaning(entryPoint))); +} +function hasBusinessOverviewDirectMoneyClarification(entryPoint) { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const businessFactFamily = toNonEmptyString(graph?.business_fact_family); + const reasonCodes = readStringArray(graph?.reason_codes); + const clarificationGaps = readStringArray(graph?.clarification_gaps); + return Boolean(businessFactFamily === "business_overview" && + reasonCodes.includes("data_need_graph_business_overview_direct_money_answer") && + (toNonEmptyString(graph?.ranking_need) || reasonCodes.includes("data_need_graph_ranking_top_desc")) && + clarificationGaps.includes("organization")); +} +function hasExactValueFlowReplyForBusinessOverviewDirectMoneyNeed(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + return Boolean(detectedIntent === "customer_revenue_and_payments" && + hasConfirmedAddressExecution(input) && + hasBusinessOverviewDirectMoneyClarification(entryPoint)); +} function hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; @@ -317,16 +384,7 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) { return false; } } - const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); - const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); - const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); - const bindingStatus = toNonEmptyString(input.addressRuntimeMeta?.capability_binding_status); - const bindingViolations = readStringArray(input.addressRuntimeMeta?.capability_binding_violations); - return Boolean(mcpCallStatus === "matched_non_empty" && - truthMode === "confirmed" && - selectedRecipe?.startsWith("address_") && - (bindingStatus === "bound" || bindingStatus === "bound_with_limits") && - bindingViolations.length === 0); + return hasConfirmedAddressExecution(input); } function hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { @@ -506,6 +564,8 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint); + const staleMetadataDiscoveryFallbackAgainstExactAddressReply = hasStaleMetadataDiscoveryFallbackAgainstExactAddressReply(input, entryPoint); + const exactValueFlowReplyForBusinessOverviewDirectMoneyNeed = hasExactValueFlowReplyForBusinessOverviewDirectMoneyNeed(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint); @@ -561,6 +621,12 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (runtimeMatchedExactReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning"); } + if (staleMetadataDiscoveryFallbackAgainstExactAddressReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_address_reply_over_stale_metadata_discovery"); + } + if (exactValueFlowReplyForBusinessOverviewDirectMoneyNeed) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_value_flow_reply_over_business_overview_direct_money_clarification"); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate"); } @@ -585,6 +651,8 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !exactMatchedFactualAddressReply && !runtimeAdjustedExactReply && !runtimeMatchedExactReply && + !staleMetadataDiscoveryFallbackAgainstExactAddressReply && + !exactValueFlowReplyForBusinessOverviewDirectMoneyNeed && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index b690c1b..06ffb9b 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2315,6 +2315,18 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio ); } + const hasTopYearRevenueRankingCue = + /(?:(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u0430\u044f|what|which)[\s\S]{0,80}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,80}(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,60}(?:\u0433\u043e\u0434|year)|(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,60}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,60}(?:\u0433\u043e\u0434|year))/iu.test( + normalized + ); + if (!hasContractCue && (hasTopYearRevenueRankingCue || hasCustomerRevenueRankingBridgeSignal(normalized))) { + return unicodeBridgeResolution( + "customer_revenue_and_payments", + "high", + "unicode_customer_revenue_ranking_bridge_signal_detected" + ); + } + if (hasOrganizationLevelEarningsOverviewBridgeSignal(normalized)) { return unicodeBridgeResolution("unknown", "high", "unicode_business_overview_earnings_deferred_to_discovery"); } @@ -3007,16 +3019,67 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio return null; } +function resolveDirectDebtSnapshotIntent(text: string): AddressIntentResolution | null { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) { + return null; + } + if (/(?:ндс|vat)/iu.test(normalized)) { + return null; + } + + const hasSnapshotCue = + /(?:кто|сколько|есть\s+ли|по\s+состоянию|на\s+сегодня|на\s+дату|срез|остаток|сальдо|баланс|на\s+(?:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\S*(?:\s+(?:19|20)\d{2})?|на\s+(?:19|20)\d{2}|as\s+of|today|current|balance)/iu.test( + normalized + ); + const hasReceivablesCue = + /(?:кто\s+(?:является\s+)?дебитором|дебитор(?:[а-яё]{0,8})?|дебиторск(?:[а-яё]{0,8})?|кто\s+нам\s+долж(?:ен|ны|на|но)?|нам\s+(?:кто-то\s+|кто\s+)?долж(?:ен|ны|на|но)?|нам\s+торч(?:ат|ит|ишь|у|али)?|к\s+получению|к\s+взысканию|who\s+owes\s+us|receivables?|accounts\s+receivable)/iu.test( + normalized + ); + const hasPayablesCue = + /(?:кто\s+(?:является\s+)?кредитором|кредитор(?:[а-яё]{0,8})?|кому\s+мы\s+долж(?:ны|н[ао])?|мы\s+долж(?:ны|н[ао])?\s+кому|мы\s+долж(?:ны|н[ао])?|к\s+оплате|who\s+we\s+owe|payables?|accounts\s+payable)/iu.test( + normalized + ); + + if (hasReceivablesCue && !hasPayablesCue && hasSnapshotCue) { + return { + intent: "receivables_confirmed_as_of_date", + confidence: "high", + reasons: ["receivables_debt_lifecycle_signal_detected", "direct_debt_snapshot_signal_detected"] + }; + } + if (hasPayablesCue && !hasReceivablesCue && hasSnapshotCue) { + return { + intent: "payables_confirmed_as_of_date", + confidence: "high", + reasons: ["payables_debt_lifecycle_signal_detected", "direct_debt_snapshot_signal_detected"] + }; + } + return null; +} + export function resolveAddressIntent(userMessage: string): AddressIntentResolution { const text = String(userMessage ?? "").trim().toLowerCase(); const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase(); const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text; const turnNoiseNormalizedBridgeText = bridgeText - .replace(/(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/giu, "$1\u043d\u0430\u043c") - .replace(/(^|[^\p{L}0-9_])\u043a\u0430\u043a\u0438\u0435\u043a(?=$|[^\p{L}0-9_])/giu, "$1\u043a\u0430\u043a\u0438\u0435"); + .replace(/(^|[^\p{L}0-9_])намс(?=$|[^\p{L}0-9_])/giu, "$1нам") + .replace(/(^|[^\p{L}0-9_])какиек(?=$|[^\p{L}0-9_])/giu, "$1какие"); const currentTurnBridgeText = turnNoiseNormalizedBridgeText !== bridgeText ? `${bridgeText} ${turnNoiseNormalizedBridgeText}` : bridgeText; + const directDebtSnapshotIntent = resolveDirectDebtSnapshotIntent(currentTurnBridgeText); + if (directDebtSnapshotIntent) { + const reasons = [...directDebtSnapshotIntent.reasons]; + if (currentTurnBridgeText !== bridgeText && !reasons.includes("current_turn_noise_normalized")) { + reasons.push("current_turn_noise_normalized"); + } + return { + ...directDebtSnapshotIntent, + reasons + }; + } + const unicodeAddressIntent = resolveUnicodeAddressIntentBridge(currentTurnBridgeText); if (unicodeAddressIntent) { const reasons = [...unicodeAddressIntent.reasons]; diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 67541e8..150225b 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -2071,10 +2071,10 @@ export function buildAddressRecipePlan( ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() : recipe.query_template === "receivables_confirmed_as_of_balance_profile" @@ -2090,10 +2090,10 @@ export function buildAddressRecipePlan( ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() : MOVEMENTS_QUERY_TEMPLATE diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 412863c..5890144 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -401,6 +401,21 @@ function normalizeQuestionText(value: string | null | undefined): string { .trim(); } +function isReportStyleBusinessQuestion(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + return /(?:обзор|анализ|подроб|разверн|оцен|аудит|report|review|analysis)/iu.test(text); +} + +function isDirectBalanceQuestion(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + if (!text || isReportStyleBusinessQuestion(text)) { + return false; + } + return /(?:кто|кому|сколько|какой|какая|какие|есть\s+ли|долж|дебитор|кредитор|payables?|receivables?|who|how\s+much)/iu.test( + text + ); +} + function hasInventoryPurchaseDateActionFocus(userMessage: string | null | undefined): boolean { const text = normalizeQuestionText(userMessage); if (!text) { @@ -780,27 +795,46 @@ function extractRequestedYearFromQuestion(userMessage: string | null | undefined } function extractCounterpartyName(row: ComposeStageRow): string | null { + const isCounterpartyLikeToken = (value: unknown, skipPattern: RegExp): string | null => { + const normalized = String(value ?? "").trim(); + if (!normalized) { + return null; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + return null; + } + if (/^\d+(?:[./-]\d+)*$/.test(normalized)) { + return null; + } + if (!/[a-zа-я]/iu.test(normalized)) { + return null; + } + if (skipPattern.test(normalized)) { + return null; + } + return normalized; + }; + const hardSkipTokenPattern = + /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|касса|расчетн|проводк|movement|invoice|payment)/iu; const skipTokenPattern = /(?:^0$|^<пусто>$|^пустая ссылка$|договор|contract|документ|операц|счет[-\s]?фактур|накладн|акт|поступлен|списани|плат[её]ж|перевод|банк|касса|расчетн|проводк|movement|invoice|payment)/iu; + const directCounterparty = isCounterpartyLikeToken(row.counterparty, hardSkipTokenPattern); + if (directCounterparty) { + return directCounterparty; + } + if (/остатки\s+на\s+дату/iu.test(row.registrator)) { + const balancePrimaryCounterparty = isCounterpartyLikeToken(row.analytics[0], hardSkipTokenPattern); + if (balancePrimaryCounterparty) { + return balancePrimaryCounterparty; + } + } + for (const token of row.analytics) { - const normalized = String(token ?? "").trim(); - if (!normalized) { - continue; + const normalized = isCounterpartyLikeToken(token, skipTokenPattern); + if (normalized) { + return normalized; } - if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { - continue; - } - if (/^\d+(?:[./-]\d+)*$/.test(normalized)) { - continue; - } - if (!/[a-zа-я]/iu.test(normalized)) { - continue; - } - if (skipTokenPattern.test(normalized)) { - continue; - } - return normalized; } for (const token of row.analytics) { const normalized = String(token ?? "").trim(); @@ -1457,6 +1491,25 @@ interface PayablesConfirmedBalanceAggregate { sourceRefs: string[]; } +interface DebtMirrorBalanceGroup { + name: string; + account: string | null; + contract: string | null; + organization: string | null; + debitAmount: number; + creditAmount: number; + offsetAmount: number; + netAmount: number; + operations: number; + sourceRefs: string[]; +} + +interface ConfirmedDebtBalanceSnapshot { + balances: PayablesConfirmedBalanceAggregate[]; + mirrorGroups: DebtMirrorBalanceGroup[]; + mirroredOffsetAmount: number; +} + function liabilityCategoryLabel(category: PayablesLiabilityCategory): string { if (category === "supplier_or_contractor") { return "поставщики/подрядчики"; @@ -1572,6 +1625,18 @@ function hasReceivablesSectionPrefix(account: string | null): boolean { return section === "62" || section === "76"; } +function normalizeSettlementAccount(value: string | null | undefined): string | null { + const normalized = String(value ?? "") + .trim() + .replace(",", "."); + return normalized || null; +} + +function extractSettlementOrganizationName(row: ComposeStageRow): string | null { + const direct = String(row.organization ?? "").trim(); + return direct || null; +} + function resolvePayablesAsOfDate(options: ComposeFactualReplyOptions): string { const explicit = normalizeIsoDateOnly(options.asOfDate); if (explicit) { @@ -1906,6 +1971,259 @@ function buildReceivablesConfirmedBalanceAggregate( }); } +function buildConfirmedDebtBalanceSnapshot( + rows: ComposeStageRow[], + asOfDate: string, + hasRelevantSectionPrefix: (account: string | null) => boolean, + positiveSide: "debit" | "credit" +): ConfirmedDebtBalanceSnapshot { + const bySettlementKey = new Map< + string, + { + name: string; + account: string | null; + contract: string | null; + organization: string | null; + debitAmount: number; + creditAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + categoryScores: Record; + reasons: Set; + contracts: Set; + documents: Set; + sourceRefs: Set; + } + >(); + const asOfTimestamp = toUtcDayTimestamp(asOfDate); + + for (const row of rows) { + const name = extractCounterpartyName(row); + if (!name) { + continue; + } + const rowTimestamp = toUtcDayTimestamp(row.period); + if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) { + continue; + } + const amount = row.amount; + if (typeof amount !== "number" || !Number.isFinite(amount)) { + continue; + } + const absAmount = Math.abs(amount); + const debitAccount = normalizeSettlementAccount(row.account_dt); + const creditAccount = normalizeSettlementAccount(row.account_kt); + const contributions: Array<{ side: "debit" | "credit"; account: string }> = []; + if (debitAccount && hasRelevantSectionPrefix(debitAccount)) { + contributions.push({ side: "debit", account: debitAccount }); + } + if (creditAccount && hasRelevantSectionPrefix(creditAccount)) { + contributions.push({ side: "credit", account: creditAccount }); + } + if (contributions.length === 0) { + continue; + } + + const contract = extractSettlementBalanceAnalyticKey(row, name); + const organization = extractSettlementOrganizationName(row); + const classified = classifyPayablesLiabilityCategory(row, name); + const sourceRefs = extractPayablesSourceRefs(row, name, contract); + + for (const contribution of contributions) { + const key = [ + normalizeEntityToken(organization), + normalizeEntityToken(contribution.account), + normalizeEntityToken(name), + normalizeEntityToken(contract) + ].join("|"); + const current = bySettlementKey.get(key); + if (!current) { + bySettlementKey.set(key, { + name, + account: contribution.account, + contract, + organization, + debitAmount: contribution.side === "debit" ? absAmount : 0, + creditAmount: contribution.side === "credit" ? absAmount : 0, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + categoryScores: { + supplier_or_contractor: classified.scores.supplier_or_contractor, + bank_or_credit: classified.scores.bank_or_credit, + tax_or_state: classified.scores.tax_or_state, + other: classified.scores.other + }, + reasons: new Set(classified.reasons), + contracts: new Set(contract ? [contract] : []), + documents: new Set(row.registrator ? [row.registrator] : []), + sourceRefs: new Set(sourceRefs) + }); + continue; + } + + if (contribution.side === "debit") { + current.debitAmount += absAmount; + } else { + current.creditAmount += absAmount; + } + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor; + current.categoryScores.bank_or_credit += classified.scores.bank_or_credit; + current.categoryScores.tax_or_state += classified.scores.tax_or_state; + current.categoryScores.other += classified.scores.other; + for (const reason of classified.reasons) { + current.reasons.add(reason); + } + if (contract) { + current.contracts.add(contract); + } + if (row.registrator) { + current.documents.add(row.registrator); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + } + + const byCounterparty = new Map< + string, + { + outstandingAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + categoryScores: Record; + reasons: Set; + contracts: Set; + documents: Set; + sourceRefs: Set; + } + >(); + const mirrorGroups: DebtMirrorBalanceGroup[] = []; + let mirroredOffsetAmount = 0; + + for (const group of bySettlementKey.values()) { + const offsetAmount = Math.min(group.debitAmount, group.creditAmount); + const netDebitMinusCredit = group.debitAmount - group.creditAmount; + if (offsetAmount > 0.005) { + mirroredOffsetAmount += offsetAmount; + mirrorGroups.push({ + name: group.name, + account: group.account, + contract: group.contract, + organization: group.organization, + debitAmount: group.debitAmount, + creditAmount: group.creditAmount, + offsetAmount, + netAmount: netDebitMinusCredit, + operations: group.operations, + sourceRefs: Array.from(group.sourceRefs).slice(0, 3) + }); + } + + const sideNetAmount = positiveSide === "credit" ? group.creditAmount - group.debitAmount : group.debitAmount - group.creditAmount; + if (sideNetAmount <= 0.005) { + continue; + } + + const current = byCounterparty.get(group.name); + if (!current) { + byCounterparty.set(group.name, { + outstandingAmount: sideNetAmount, + operations: group.operations, + firstPeriod: group.firstPeriod, + lastPeriod: group.lastPeriod, + categoryScores: { + supplier_or_contractor: group.categoryScores.supplier_or_contractor, + bank_or_credit: group.categoryScores.bank_or_credit, + tax_or_state: group.categoryScores.tax_or_state, + other: group.categoryScores.other + }, + reasons: new Set(group.reasons), + contracts: new Set(group.contracts), + documents: new Set(group.documents), + sourceRefs: new Set(group.sourceRefs) + }); + continue; + } + + current.outstandingAmount += sideNetAmount; + current.operations += group.operations; + if ((group.firstPeriod ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = group.firstPeriod; + } + if ((group.lastPeriod ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = group.lastPeriod; + } + current.categoryScores.supplier_or_contractor += group.categoryScores.supplier_or_contractor; + current.categoryScores.bank_or_credit += group.categoryScores.bank_or_credit; + current.categoryScores.tax_or_state += group.categoryScores.tax_or_state; + current.categoryScores.other += group.categoryScores.other; + for (const reason of group.reasons) { + current.reasons.add(reason); + } + for (const contract of group.contracts) { + current.contracts.add(contract); + } + for (const document of group.documents) { + current.documents.add(document); + } + for (const ref of group.sourceRefs) { + current.sourceRefs.add(ref); + } + } + + return { + balances: Array.from(byCounterparty.entries()) + .map(([name, item]) => ({ + name, + outstandingAmount: item.outstandingAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + category: resolvePayablesLiabilityCategory(item.categoryScores), + categoryReasons: Array.from(item.reasons).slice(0, 2), + contracts: Array.from(item.contracts).slice(0, 2), + documents: Array.from(item.documents).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) + })) + .filter((item) => item.outstandingAmount > 0.005) + .sort((left, right) => { + if (right.outstandingAmount !== left.outstandingAmount) { + return right.outstandingAmount - left.outstandingAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.name.localeCompare(right.name); + }), + mirrorGroups: mirrorGroups.sort((left, right) => { + if (right.offsetAmount !== left.offsetAmount) { + return right.offsetAmount - left.offsetAmount; + } + return left.name.localeCompare(right.name); + }), + mirroredOffsetAmount + }; +} + +function buildPayablesConfirmedBalanceSnapshot(rows: ComposeStageRow[], asOfDate: string): ConfirmedDebtBalanceSnapshot { + return buildConfirmedDebtBalanceSnapshot(rows, asOfDate, hasPayablesSectionPrefix, "credit"); +} + +function buildReceivablesConfirmedBalanceSnapshot(rows: ComposeStageRow[], asOfDate: string): ConfirmedDebtBalanceSnapshot { + return buildConfirmedDebtBalanceSnapshot(rows, asOfDate, hasReceivablesSectionPrefix, "debit"); +} + function buildCounterpartyRiskAggregate(rows: ComposeStageRow[]): CounterpartyRiskAggregate[] { const byCounterparty = new Map(); @@ -2109,6 +2427,51 @@ function extractContractName(row: ComposeStageRow): string | null { return null; } +function extractSettlementBalanceAnalyticKey(row: ComposeStageRow, counterparty: string): string | null { + const counterpartyToken = normalizeSettlementComparableToken(counterparty); + const organizationToken = normalizeSettlementComparableToken(extractSettlementOrganizationName(row)); + const contract = extractContractName(row); + if (contract) { + const contractToken = normalizeSettlementComparableToken(contract); + if ( + contractToken && + contractToken !== counterpartyToken && + contractToken !== organizationToken && + !(Boolean(organizationToken) && contractToken.includes(organizationToken)) && + !/^организац/.test(contractToken) + ) { + return contract; + } + } + + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + const normalizedToken = normalizeSettlementComparableToken(normalized); + if (!normalized || !normalizedToken) { + continue; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) { + continue; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized) || /^\d+(?:[.,]\d+)?$/.test(normalized)) { + continue; + } + if (/^\d{2}(?:\.\d{1,2})?$/.test(normalized)) { + continue; + } + if ( + normalizedToken === counterpartyToken || + normalizedToken === organizationToken || + (Boolean(organizationToken) && normalizedToken.includes(organizationToken)) || + /^организац/.test(normalizedToken) + ) { + continue; + } + return normalized; + } + return null; +} + function normalizeEntityToken(value: string | null | undefined): string { return String(value ?? "") .toLowerCase() @@ -2117,6 +2480,13 @@ function normalizeEntityToken(value: string | null | undefined): string { .trim(); } +function normalizeSettlementComparableToken(value: string | null | undefined): string { + return normalizeEntityToken(value) + .replace(/[^\p{L}0-9]+/giu, " ") + .replace(/\s+/g, " ") + .trim(); +} + function extractPayablesSourceRefs( row: ComposeStageRow, counterparty: string, @@ -2172,6 +2542,58 @@ function formatPayablesEvidenceSuffix(item: PayablesConfirmedBalanceAggregate): return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; } +function formatDebtMirrorGroupLine(item: DebtMirrorBalanceGroup): string { + const details = [ + item.account ? `счет ${item.account}` : null, + item.contract ? `договор/аналитика: ${item.contract}` : null, + item.organization ? `организация: ${item.organization}` : null + ].filter((part): part is string => Boolean(part)); + const netText = + Math.abs(item.netAmount) <= 0.005 + ? "чисто: 0 ₽" + : item.netAmount > 0 + ? `чисто к получению: ${formatMoneyRub(item.netAmount)}` + : `чисто к оплате: ${formatMoneyRub(Math.abs(item.netAmount))}`; + return `${item.name}${details.length > 0 ? ` (${details.join(", ")})` : ""}: дебет ${formatMoneyRub(item.debitAmount)} / кредит ${formatMoneyRub(item.creditAmount)}, ${netText}.`; +} + +function debtMirrorCleanScopeLabel(kind: "payables" | "receivables"): string { + return kind === "payables" ? "чистый долг к оплате" : "чистую дебиторку к получению"; +} + +function appendDebtMirrorCompactDisclosure( + lines: string[], + snapshot: ConfirmedDebtBalanceSnapshot, + kind: "payables" | "receivables" +): void { + if (snapshot.mirroredOffsetAmount <= 0.005) { + return; + } + lines.push( + `Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.` + ); + const leadingMirror = snapshot.mirrorGroups[0] ?? null; + if (leadingMirror) { + lines.push(`Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`); + } +} + +function appendDebtMirrorDisclosure( + lines: string[], + snapshot: ConfirmedDebtBalanceSnapshot, + kind: "payables" | "receivables" +): void { + if (snapshot.mirroredOffsetAmount <= 0.005) { + return; + } + lines.push(""); + lines.push("Встречные остатки к сверке"); + lines.push( + `- Встречная часть: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; она исключена из ${debtMirrorCleanScopeLabel(kind)}.` + ); + lines.push(...snapshot.mirrorGroups.slice(0, 3).map((item, index) => `${index + 1}. ${formatDebtMirrorGroupLine(item)}`)); +} + function deriveOperationalYearWindow( yearDocs: YearAggPoint[], yearOps: YearAggPoint[] @@ -3813,7 +4235,8 @@ function composeFactualReplyBody( if (intent === "payables_confirmed_as_of_date") { const payablesAsOfDate = resolvePayablesAsOfDate(options); - const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate); + const balanceSnapshot = buildPayablesConfirmedBalanceSnapshot(rows, payablesAsOfDate); + const confirmedBalances = balanceSnapshot.balances; const asOfDate = normalizeIsoDateOnly(options.asOfDate); const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodTo = normalizeIsoDateOnly(options.periodTo); @@ -3834,6 +4257,39 @@ function composeFactualReplyBody( { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 } ); + if (isDirectBalanceQuestion(options.userMessage)) { + const leading = confirmedBalances[0] ?? null; + const compactLines: string[] = leading + ? [ + `Коротко: на ${formatDateRu(payablesAsOfDate)} мы должны ${formatMoneyRub(totalOutstandingAmount)}; крупнейшая позиция — ${leading.name} (${formatMoneyRub(leading.outstandingAmount)}).`, + "Крупнейшие позиции к оплате:" + ] + : [`Коротко: на ${formatDateRu(payablesAsOfDate)} подтвержденных обязательств к оплате не найдено.`]; + + if (leading) { + compactLines.push( + ...confirmedBalances.slice(0, 5).map((item, index) => { + const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : ""; + return `${index + 1}. ${item.name} — ${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`; + }) + ); + if (confirmedBalances.length > 5) { + compactLines.push(`Показаны первые 5 из ${formatNumberWithDots(confirmedBalances.length)} подтвержденных позиций.`); + } + } + appendDebtMirrorCompactDisclosure(compactLines, balanceSnapshot, "payables"); + compactLines.push(`Основа: подтвержденный остаток по счетам 60/76, срез ${formatDateRu(payablesAsOfDate)}.`); + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: joinLines(compactLines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } + const lines: string[] = [ `Коротко: подтвержденный долг к оплате на ${formatDateRu(payablesAsOfDate)} — ${formatMoneyRub(totalOutstandingAmount)}.`, "Это подтвержденный срез обязательств к оплате по точному остатку." @@ -3854,6 +4310,7 @@ function composeFactualReplyBody( lines.push("Сводка"); lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${formatNumberWithDots(confirmedBalances.length)}.`); + appendDebtMirrorDisclosure(lines, balanceSnapshot, "payables"); lines.push(""); lines.push("Категории обязательств"); @@ -3891,7 +4348,8 @@ function composeFactualReplyBody( if (intent === "receivables_confirmed_as_of_date") { const receivablesAsOfDate = resolveReceivablesAsOfDate(options); - const confirmedBalances = buildReceivablesConfirmedBalanceAggregate(rows, receivablesAsOfDate); + const balanceSnapshot = buildReceivablesConfirmedBalanceSnapshot(rows, receivablesAsOfDate); + const confirmedBalances = balanceSnapshot.balances; const asOfDate = normalizeIsoDateOnly(options.asOfDate); const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodTo = normalizeIsoDateOnly(options.periodTo); @@ -3912,6 +4370,39 @@ function composeFactualReplyBody( { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 } ); + if (isDirectBalanceQuestion(options.userMessage)) { + const leading = confirmedBalances[0] ?? null; + const compactLines: string[] = leading + ? [ + `Коротко: на ${formatDateRu(receivablesAsOfDate)} нам должны ${formatMoneyRub(totalOutstandingAmount)}; крупнейшая позиция — ${leading.name} (${formatMoneyRub(leading.outstandingAmount)}).`, + "Крупнейшие позиции к получению:" + ] + : [`Коротко: на ${formatDateRu(receivablesAsOfDate)} подтвержденной дебиторской задолженности не найдено.`]; + + if (leading) { + compactLines.push( + ...confirmedBalances.slice(0, 5).map((item, index) => { + const lastPeriod = item.lastPeriod ? `, последнее движение: ${item.lastPeriod}` : ""; + return `${index + 1}. ${item.name} — ${formatMoneyRub(item.outstandingAmount)} (${formatNumberWithDots(item.operations)} опер.${lastPeriod}).`; + }) + ); + if (confirmedBalances.length > 5) { + compactLines.push(`Показаны первые 5 из ${formatNumberWithDots(confirmedBalances.length)} подтвержденных позиций.`); + } + } + appendDebtMirrorCompactDisclosure(compactLines, balanceSnapshot, "receivables"); + compactLines.push(`Основа: подтвержденный остаток по счетам 62/76, срез ${formatDateRu(receivablesAsOfDate)}.`); + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: joinLines(compactLines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", + balance_confirmed: true + } + }; + } + const lines: string[] = [ `Коротко: подтвержденная дебиторская задолженность на ${formatDateRu(receivablesAsOfDate)} — ${formatMoneyRub(totalOutstandingAmount)}.`, "Это подтвержденный срез дебиторской задолженности, а не эвристический shortlist." @@ -3932,6 +4423,7 @@ function composeFactualReplyBody( lines.push("Сводка"); lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); lines.push(`- Контрагентов с подтвержденным остатком к получению: ${formatNumberWithDots(confirmedBalances.length)}.`); + appendDebtMirrorDisclosure(lines, balanceSnapshot, "receivables"); lines.push(""); lines.push("Категории дебиторской задолженности"); @@ -4062,7 +4554,8 @@ function composeFactualReplyBody( }; if (options.requestedResultMode === "confirmed_balance") { - const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate); + const balanceSnapshot = buildPayablesConfirmedBalanceSnapshot(rows, payablesAsOfDate); + const confirmedBalances = balanceSnapshot.balances; if (confirmedBalances.length > 0) { const categoryCounts = confirmedBalances.reduce>( (acc, item) => { @@ -4103,6 +4596,7 @@ function composeFactualReplyBody( `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoneyRub(item.outstandingAmount)} | операций в срезе: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}` ) ]; + appendDebtMirrorDisclosure(lines, balanceSnapshot, "payables"); return { responseType: "FACTUAL_LIST", text: joinLines(lines), diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts index dec52cd..3737b98 100644 --- a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -534,7 +534,18 @@ export function composeCounterpartyAnalyticsReply( !/(?:\btop\b|топ|рейтинг|список|первые|покажи\s+топ|дай\s+топ|покаж\w*\s+топ|дай\s+топ)/iu.test( normalizedQuestion ); - const effectiveLimit = asksSingleBestCounterparty ? 1 : limit; + const asksExplicitRankingList = + /(?:\btop\b|топ|рейтинг|список|первые|покажи\s+(?:топ|список)|дай\s+(?:топ|список)|show\s+(?:top|list))/iu.test( + normalizedQuestion + ); + const hasSingleBestCounterpartyCue = + /(?:сам\p{L}*|больше\s+всего|наибольш|прин[её]с|определ\p{L}*|найд\p{L}*|highest|largest|most)/iu.test( + normalizedQuestion + ) && + /(?:клиент|заказчик|покупател|контрагент|customer|client|counterparty|buyer)/iu.test(normalizedQuestion); + const semanticSingleBestCounterparty = + focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList; + const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit; const byCounterparty = new Map(); const byYear = new Map(); diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index faf2304..597d613 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -220,6 +220,14 @@ function hasExplicitLooseByAnchorToken(text: string): boolean { } const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([ + "мы", + "нам", + "нас", + "наш", + "наша", + "наше", + "наши", + "унас", "есть", "же", "что", @@ -1445,6 +1453,12 @@ function mergeFollowupFilters( previousOrganization ?? (followupContext.previous_anchor_type === "organization" ? previousAnchorValue : null); const finalCounterparty = toNonEmptyString(merged.counterparty); + if (finalCounterparty && isLowQualityCounterpartyAnchor(finalCounterparty)) { + delete merged.counterparty; + if (!reasons.includes("counterparty_cleared_low_quality_followup_anchor")) { + reasons.push("counterparty_cleared_low_quality_followup_anchor"); + } + } if (shouldSuppressInventoryCounterpartyAlias(intent, finalCounterparty, finalOrganizationReference)) { delete merged.counterparty; if (!reasons.includes("counterparty_cleared_as_organization_scope_alias")) { diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index f469c8c..4f6dfb8 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -575,6 +575,14 @@ function isReferentialCounterpartyPlaceholder( return false; } return new Set([ + "мы", + "нам", + "нас", + "наш", + "наша", + "наше", + "наши", + "унас", "он", "она", "оно", diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index a37df78..73538e8 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -733,6 +733,37 @@ function businessOverviewDebtDueDateAgingText(overview: BusinessOverview): strin return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`; } +function businessOverviewVendorProcurementQualityText(overview: BusinessOverview): string | null { + const quality = overview.vendor_procurement_quality; + if (!quality) { + return null; + } + const period = quality.period_scope ?? "проверенное окно"; + const total = quality.total_outgoing_amount_human_ru; + const top = quality.top_outgoing_counterparty; + const topName = top?.axis_value ?? "получатель не распознан"; + const topShare = quality.top_outgoing_share_pct === null ? "" : `, около ${quality.top_outgoing_share_pct}%`; + const topAmount = top?.total_amount_human_ru ? ` (${top.total_amount_human_ru})` : ""; + const nonFinancial = quality.top_non_financial_supplier; + const nonFinancialShare = + quality.top_non_financial_supplier_share_pct === null ? "" : `, около ${quality.top_non_financial_supplier_share_pct}%`; + const nonFinancialText = nonFinancial + ? ` Крупнейший небанковский получатель: ${nonFinancial.axis_value}${nonFinancialShare}${nonFinancial.total_amount_human_ru ? ` (${nonFinancial.total_amount_human_ru})` : ""}.` + : ""; + const contractText = quality.used_contracts === null + ? "" + : quality.total_contracts === null + ? ` Договорный профиль: используется ${quality.used_contracts} договоров.` + : ` Договорный профиль: используется ${quality.used_contracts}/${quality.total_contracts} договоров${quality.used_contract_share_pct === null ? "" : ` (${quality.used_contract_share_pct}%)`}.`; + if (quality.evidence_status === "financial_institution_leads_outgoing_cash") { + return `Проверенный procurement-concentration route за ${period}: крупнейший получатель исходящих денег ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}. По названию это банк/финансовая организация, поэтому зависимость от обычного поставщика этим не подтверждается.${nonFinancialText}${contractText} Надежность поставщиков, качество поставок, назначение каждого платежа и полная структура расходов этим маршрутом не доказаны.`; + } + if (quality.evidence_status === "reviewed_procurement_concentration") { + return `Проверенный procurement-concentration route за ${period}: крупнейший поставщик/получатель исходящих платежей ${topName}${topShare}${topAmount}, всего исходящих платежей ${total}.${contractText} Это проверенный сигнал концентрации закупок/исходящих платежей, но не аудит надежности поставщика, качества поставок и полной структуры расходов.`; + } + return `Procurement-concentration route за ${period} отработал по исходящим платежам на ${total}, но надежной небанковской концентрации поставщика по найденным строкам не хватает.${contractText} Полный vendor-risk аудит не подтвержден.`; +} + function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string { const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || @@ -774,6 +805,10 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD return `Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя; ${inventoryBasis}. Можно честно говорить только о необходимости отдельной проверки склада, списаний/резервов и ликвидационной стоимости, не превращая proxy в доказанный факт резерва.`; } if (isVendorRiskBoundaryTurn(pilot)) { + const vendorQualityText = businessOverviewVendorProcurementQualityText(overview); + if (vendorQualityText) { + return vendorQualityText; + } const supplierLeader = overview.top_suppliers?.[0] ?? null; const proxyLabel = isFinancialInstitutionBucket(supplierLeader) ? "outgoing cash concentration proxy" @@ -1051,7 +1086,11 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): claims.push("Do not present business overview cash-flow spread as profit or margin."); claims.push("Do not present business overview yearly operating-flow breakdown as profit, financial result, or a complete annual P&L."); claims.push("Do not present business overview trading-margin proxy as clean profit, accounting financial result, or exact cost-of-sales margin."); - claims.push("Do not present business overview supplier concentration as vendor-risk audit, procurement quality, or full expense structure."); + if (pilot.derived_business_overview?.vendor_procurement_quality) { + claims.push("Do not present reviewed procurement concentration as supplier reliability, delivery quality, payment-purpose classification, or full expense structure."); + } else { + claims.push("Do not present business overview supplier concentration as vendor-risk audit, procurement quality, or full expense structure."); + } claims.push("Do not present business overview document/account-section activity profile as process quality, accounting correctness, or completeness of all 1C activity."); claims.push("Do not present business overview counterparty or contract profile as CRM quality, counterparty due diligence, contract-risk audit, or legal completeness."); claims.push("Do not claim debt quality, VAT position, inventory health, or company health unless those contours were separately checked."); @@ -1486,6 +1525,10 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot if (outgoingLeaderLine) { lines.push(outgoingLeaderLine); } + const vendorQualityText = businessOverviewVendorProcurementQualityText(overview); + if (vendorQualityText) { + lines.push(vendorQualityText); + } if (overview.yearly_breakdown?.length) { lines.push( `Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(overview.yearly_breakdown.length)}.` @@ -1936,6 +1979,10 @@ export function buildAssistantMcpDiscoveryAnswerDraft( if (pilot.derived_business_overview?.top_suppliers?.length) { pushReason(reasonCodes, "answer_contains_business_overview_supplier_concentration"); } + if (pilot.derived_business_overview?.vendor_procurement_quality) { + pushReason(reasonCodes, "answer_contains_business_overview_vendor_procurement_quality"); + pushReason(reasonCodes, `answer_contains_business_overview_vendor_procurement_quality_${pilot.derived_business_overview.vendor_procurement_quality.evidence_status}`); + } if (pilot.derived_business_overview?.yearly_breakdown?.length) { pushReason(reasonCodes, "answer_contains_business_overview_yearly_operating_breakdown"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index e7a6c4a..4cb886d 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -188,6 +188,28 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewContractUsageProfil inference_basis: "contract_usage_overview_confirmed_1c_rows"; } +export interface AssistantMcpDiscoveryDerivedBusinessOverviewVendorProcurementQuality { + period_scope: string | null; + rows_with_amount: number; + total_outgoing_amount: number; + total_outgoing_amount_human_ru: string; + top_outgoing_counterparty: AssistantMcpDiscoveryRankedValueFlowBucket | null; + top_outgoing_share_pct: number | null; + top_non_financial_supplier: AssistantMcpDiscoveryRankedValueFlowBucket | null; + top_non_financial_supplier_share_pct: number | null; + financial_institution_leads_outgoing_cash: boolean; + supplier_only_count: number | null; + mixed_role_count: number | null; + used_contracts: number | null; + total_contracts: number | null; + used_contract_share_pct: number | null; + evidence_status: + | "reviewed_procurement_concentration" + | "financial_institution_leads_outgoing_cash" + | "insufficient_supplier_concentration_basis"; + inference_basis: "supplier_payout_concentration_counterparty_contract_profile_confirmed_1c_rows"; +} + export type AssistantMcpDiscoveryBusinessOverviewMissingProofFamilyId = | "accounting_profit_margin" | "debt_due_date_aging_quality" @@ -246,6 +268,7 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverview { document_activity_profile: AssistantMcpDiscoveryDerivedBusinessOverviewDocumentActivityProfile | null; counterparty_profile: AssistantMcpDiscoveryDerivedBusinessOverviewCounterpartyProfile | null; contract_usage_profile: AssistantMcpDiscoveryDerivedBusinessOverviewContractUsageProfile | null; + vendor_procurement_quality: AssistantMcpDiscoveryDerivedBusinessOverviewVendorProcurementQuality | null; coverage_limited_by_probe_limit: boolean; checked_signal_count: number; missing_signal_families: string[]; @@ -4125,6 +4148,59 @@ function deriveBusinessOverviewInventoryStalenessRiskProxy(input: { }; } +function deriveBusinessOverviewVendorProcurementQuality(input: { + rankedOutgoing: AssistantMcpDiscoveryDerivedRankedValueFlow | null; + outgoing: AssistantMcpDiscoveryValueFlowSideSummary; + counterpartyProfile: AssistantMcpDiscoveryDerivedBusinessOverviewCounterpartyProfile | null; + contractUsageProfile: AssistantMcpDiscoveryDerivedBusinessOverviewContractUsageProfile | null; + periodScope: string | null; +}): AssistantMcpDiscoveryDerivedBusinessOverviewVendorProcurementQuality | null { + if ( + !input.rankedOutgoing || + input.rankedOutgoing.ranked_values.length <= 0 || + input.outgoing.rows_with_amount <= 0 || + input.outgoing.total_amount <= 0 + ) { + return null; + } + + const topOutgoingCounterparty = input.rankedOutgoing.ranked_values[0] ?? null; + const topNonFinancialSupplier = + input.rankedOutgoing.ranked_values.find((item) => !isLikelyFinancialInstitutionCounterparty(item.axis_value)) ?? null; + const financialInstitutionLeadsOutgoingCash = topOutgoingCounterparty + ? isLikelyFinancialInstitutionCounterparty(topOutgoingCounterparty.axis_value) + : false; + const evidenceStatus: AssistantMcpDiscoveryDerivedBusinessOverviewVendorProcurementQuality["evidence_status"] = + !topNonFinancialSupplier + ? "insufficient_supplier_concentration_basis" + : financialInstitutionLeadsOutgoingCash + ? "financial_institution_leads_outgoing_cash" + : "reviewed_procurement_concentration"; + + return { + period_scope: input.periodScope, + rows_with_amount: input.outgoing.rows_with_amount, + total_outgoing_amount: input.outgoing.total_amount, + total_outgoing_amount_human_ru: input.outgoing.total_amount_human_ru, + top_outgoing_counterparty: topOutgoingCounterparty, + top_outgoing_share_pct: topOutgoingCounterparty + ? percentageOfTotal(topOutgoingCounterparty.total_amount, input.outgoing.total_amount) + : null, + top_non_financial_supplier: topNonFinancialSupplier, + top_non_financial_supplier_share_pct: topNonFinancialSupplier + ? percentageOfTotal(topNonFinancialSupplier.total_amount, input.outgoing.total_amount) + : null, + financial_institution_leads_outgoing_cash: financialInstitutionLeadsOutgoingCash, + supplier_only_count: input.counterpartyProfile?.supplier_only_count ?? null, + mixed_role_count: input.counterpartyProfile?.mixed_role_count ?? null, + used_contracts: input.contractUsageProfile?.used_contracts ?? null, + total_contracts: input.contractUsageProfile?.total_contracts ?? null, + used_contract_share_pct: input.contractUsageProfile?.used_contract_share_pct ?? null, + evidence_status: evidenceStatus, + inference_basis: "supplier_payout_concentration_counterparty_contract_profile_confirmed_1c_rows" + }; +} + function inventoryStalenessRiskBandRu( riskBand: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryStalenessRiskProxy["risk_band"] ): string { @@ -4150,6 +4226,7 @@ function buildBusinessOverviewMissingProofFamilies(input: { inventoryPosition: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null; inventoryTurnoverProxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryTurnoverProxy | null; inventoryStalenessRiskProxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryStalenessRiskProxy | null; + vendorProcurementQuality: AssistantMcpDiscoveryDerivedBusinessOverviewVendorProcurementQuality | null; hasSupplierConcentrationSignal: boolean; }): AssistantMcpDiscoveryBusinessOverviewMissingProofFamily[] { const missing = new Set(input.missingSignalFamilies); @@ -4215,7 +4292,7 @@ function buildBusinessOverviewMissingProofFamilies(input: { }); } - if (input.hasSupplierConcentrationSignal) { + if (input.hasSupplierConcentrationSignal && !input.vendorProcurementQuality) { pushUnique({ family: "vendor_risk_procurement_quality", current_status: "proxy_only_currently", @@ -4313,6 +4390,13 @@ function deriveBusinessOverview(input: { inventoryPosition, inventoryTurnoverProxy }); + const vendorProcurementQuality = deriveBusinessOverviewVendorProcurementQuality({ + rankedOutgoing, + outgoing, + counterpartyProfile, + contractUsageProfile, + periodScope: input.periodScope + }); const checkedSignalCount = [ incoming.rows_with_amount > 0, outgoing.rows_with_amount > 0, @@ -4329,7 +4413,8 @@ function deriveBusinessOverview(input: { Boolean(contractUsageProfile), Boolean(inventoryPosition), Boolean(inventoryTurnoverProxy), - Boolean(inventoryStalenessRiskProxy) + Boolean(inventoryStalenessRiskProxy), + Boolean(vendorProcurementQuality) ].filter(Boolean).length; if (checkedSignalCount <= 0) { return null; @@ -4363,6 +4448,7 @@ function deriveBusinessOverview(input: { inventoryPosition, inventoryTurnoverProxy, inventoryStalenessRiskProxy, + vendorProcurementQuality, hasSupplierConcentrationSignal: (rankedOutgoing?.ranked_values.length ?? 0) > 0 }); return { @@ -4390,6 +4476,7 @@ function deriveBusinessOverview(input: { document_activity_profile: documentActivityProfile, counterparty_profile: counterpartyProfile, contract_usage_profile: contractUsageProfile, + vendor_procurement_quality: vendorProcurementQuality, coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, checked_signal_count: checkedSignalCount, @@ -5986,6 +6073,10 @@ export async function executeAssistantMcpDiscoveryPilot( if (derivedBusinessOverview.top_suppliers.length > 0) { pushReason(reasonCodes, "pilot_derived_business_overview_top_suppliers_from_confirmed_rows"); } + if (derivedBusinessOverview.vendor_procurement_quality) { + pushReason(reasonCodes, "pilot_derived_business_overview_vendor_procurement_quality_from_confirmed_rows"); + pushReason(reasonCodes, `pilot_derived_business_overview_vendor_procurement_quality_${derivedBusinessOverview.vendor_procurement_quality.evidence_status}`); + } if (derivedBusinessOverview.yearly_breakdown.length > 0) { pushReason(reasonCodes, "pilot_derived_business_overview_yearly_operating_breakdown_from_confirmed_rows"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index 448c786..a5b8940 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -843,7 +843,7 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { extraReasons: primitiveSelection.reasonCodes }); } - if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) { + if (dataNeedGraph?.ranking_need) { pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -864,7 +864,10 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { dataNeedGraph.ranking_need === "bottom_asc" ? "planner_selected_bottom_ranked_value_flow_from_data_need_graph" : "planner_selected_top_ranked_value_flow_from_data_need_graph", - extraReasons: primitiveSelection.reasonCodes + extraReasons: [ + ...primitiveSelection.reasonCodes, + ...(hasSubjectCandidates(dataNeedGraph) ? ["planner_kept_ranking_over_subject_scope_candidates"] : []) + ] }); } if (openScopeTotalWithoutSubject) { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 0f83756..a98ea19 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -922,6 +922,58 @@ function buildCompactBusinessOverviewReply( } if (vendorRiskBoundary) { + const vendorProcurementQuality = toRecordObject(overview.vendor_procurement_quality); + if (vendorProcurementQuality) { + const status = toNonEmptyString(vendorProcurementQuality.evidence_status); + const totalOutgoing = moneyText(vendorProcurementQuality.total_outgoing_amount_human_ru); + const topOutgoingRecord = toRecordObject(vendorProcurementQuality.top_outgoing_counterparty); + const topOutgoingName = toNonEmptyString(topOutgoingRecord?.axis_value); + const topOutgoingAmount = moneyText(topOutgoingRecord?.total_amount_human_ru); + const topOutgoingShare = + typeof vendorProcurementQuality.top_outgoing_share_pct === "number" && + Number.isFinite(vendorProcurementQuality.top_outgoing_share_pct) + ? `${vendorProcurementQuality.top_outgoing_share_pct}%` + : null; + const nonFinancialRecord = toRecordObject(vendorProcurementQuality.top_non_financial_supplier); + const nonFinancialName = toNonEmptyString(nonFinancialRecord?.axis_value); + const nonFinancialAmount = moneyText(nonFinancialRecord?.total_amount_human_ru); + const nonFinancialShare = + typeof vendorProcurementQuality.top_non_financial_supplier_share_pct === "number" && + Number.isFinite(vendorProcurementQuality.top_non_financial_supplier_share_pct) + ? `${vendorProcurementQuality.top_non_financial_supplier_share_pct}%` + : null; + const periodScope = toNonEmptyString(vendorProcurementQuality.period_scope) ?? period; + const totalText = totalOutgoing ? `; всего исходящих платежей в проверенном срезе ${totalOutgoing}` : ""; + if (status === "financial_institution_leads_outgoing_cash") { + lines.push( + `Коротко: проверенный procurement-concentration route за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.` + ); + if (nonFinancialName) { + lines.push( + `Крупнейший небанковский получатель исходящих денег: ${nonFinancialName}${nonFinancialShare ? `, около ${nonFinancialShare}` : ""}${nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}. Это уже сигнал закупочной/исходящей концентрации, но не аудит надежности поставщика.` + ); + } + } else if (status === "reviewed_procurement_concentration") { + lines.push( + `Коротко: проверенный procurement-concentration route за ${periodScope} нашел основную зависимость исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.` + ); + } else { + lines.push( + `Коротко: procurement-concentration route за ${periodScope} отработал, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.` + ); + } + const contractText = + typeof vendorProcurementQuality.used_contracts === "number" && Number.isFinite(vendorProcurementQuality.used_contracts) + ? typeof vendorProcurementQuality.total_contracts === "number" && Number.isFinite(vendorProcurementQuality.total_contracts) + ? ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts}/${vendorProcurementQuality.total_contracts} договоров${typeof vendorProcurementQuality.used_contract_share_pct === "number" && Number.isFinite(vendorProcurementQuality.used_contract_share_pct) ? ` (${vendorProcurementQuality.used_contract_share_pct}%)` : ""}.` + : ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts} договоров.` + : ""; + lines.push( + `Что не доказано этим маршрутом: надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полная структура всех расходов.${contractText}` + ); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } const supplierBasis = topSupplier ? topSupplierLooksFinancial ? `крупнейший получатель исходящих денег: ${topSupplier}; по названию это банк/финансовая организация, поэтому это не доказанная зависимость от обычного поставщика${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}` diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 56a6d2c..7d5bc4d 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -255,6 +255,26 @@ function isMetadataDiscoveryTurn( ); } +function isExplicitMetadataDiscoveryTurn( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const turnInput = toRecordObject(entryPoint?.turn_input); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const graph = readDiscoveryDataNeedGraph(entryPoint); + const reasonCodes = [ + ...(Array.isArray(entryPoint?.reason_codes) ? entryPoint.reason_codes : []), + ...(Array.isArray(turnInput?.reason_codes) ? turnInput.reason_codes : []) + ]; + const decompositionCandidates = Array.isArray(graph?.decomposition_candidates) ? graph.decomposition_candidates : []; + return Boolean( + toNonEmptyString(turnMeaning?.asked_domain_family) === "metadata" || + toNonEmptyString(turnMeaning?.unsupported_but_understood_family) === "1c_metadata_surface" || + toNonEmptyString(graph?.business_fact_family) === "schema_surface" || + decompositionCandidates.some((candidate) => toNonEmptyString(candidate) === "inspect_metadata_surface") || + reasonCodes.some((reason) => toNonEmptyString(reason) === "mcp_discovery_metadata_signal_detected") + ); +} + function isInventoryExactAddressIntent(intent: string | null): boolean { return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test( String(intent ?? "") @@ -274,6 +294,9 @@ function hasMetadataDiscoveryPriority( if (!isMetadataDiscoveryTurn(entryPoint)) { return false; } + if (!isExplicitMetadataDiscoveryTurn(entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); return !isInventoryExactAddressIntent(detectedIntent); } @@ -363,6 +386,75 @@ function readStringArray(value: unknown): string[] { : []; } +function hasConfirmedAddressExecution(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { + const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); + const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const bindingStatus = toNonEmptyString(input.addressRuntimeMeta?.capability_binding_status); + const bindingViolations = readStringArray(input.addressRuntimeMeta?.capability_binding_violations); + return Boolean( + mcpCallStatus === "matched_non_empty" && + truthMode === "confirmed" && + selectedRecipe?.startsWith("address_") && + (bindingStatus === "bound" || bindingStatus === "bound_with_limits") && + bindingViolations.length === 0 + ); +} + +function hasStaleMetadataDiscoveryFallbackAgainstExactAddressReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (!isMetadataDiscoveryTurn(entryPoint) || isExplicitMetadataDiscoveryTurn(entryPoint)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + return Boolean( + detectedIntent && + hasConfirmedAddressExecution(input) && + isDetectedIntentAlignedWithTurnMeaning(detectedIntent, readDiscoveryTurnMeaning(entryPoint)) + ); +} + +function hasBusinessOverviewDirectMoneyClarification( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const businessFactFamily = toNonEmptyString(graph?.business_fact_family); + const reasonCodes = readStringArray(graph?.reason_codes); + const clarificationGaps = readStringArray(graph?.clarification_gaps); + return Boolean( + businessFactFamily === "business_overview" && + reasonCodes.includes("data_need_graph_business_overview_direct_money_answer") && + (toNonEmptyString(graph?.ranking_need) || reasonCodes.includes("data_need_graph_ranking_top_desc")) && + clarificationGaps.includes("organization") + ); +} + +function hasExactValueFlowReplyForBusinessOverviewDirectMoneyNeed( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + return Boolean( + detectedIntent === "customer_revenue_and_payments" && + hasConfirmedAddressExecution(input) && + hasBusinessOverviewDirectMoneyClarification(entryPoint) + ); +} + function hasValueFlowActionConflictWithDiscoveryTurnMeaning( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -443,18 +535,7 @@ function hasExactMatchedFactualAddressReply( return false; } } - const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); - const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); - const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); - const bindingStatus = toNonEmptyString(input.addressRuntimeMeta?.capability_binding_status); - const bindingViolations = readStringArray(input.addressRuntimeMeta?.capability_binding_violations); - return Boolean( - mcpCallStatus === "matched_non_empty" && - truthMode === "confirmed" && - selectedRecipe?.startsWith("address_") && - (bindingStatus === "bound" || bindingStatus === "bound_with_limits") && - bindingViolations.length === 0 - ); + return hasConfirmedAddressExecution(input); } function hasOpenScopeValueFlowDiscoveryPriority( @@ -682,6 +763,14 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint); + const staleMetadataDiscoveryFallbackAgainstExactAddressReply = hasStaleMetadataDiscoveryFallbackAgainstExactAddressReply( + input, + entryPoint + ); + const exactValueFlowReplyForBusinessOverviewDirectMoneyNeed = hasExactValueFlowReplyForBusinessOverviewDirectMoneyNeed( + input, + entryPoint + ); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning( @@ -750,6 +839,18 @@ export function applyAssistantMcpDiscoveryResponsePolicy( "mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning" ); } + if (staleMetadataDiscoveryFallbackAgainstExactAddressReply) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_keep_exact_address_reply_over_stale_metadata_discovery" + ); + } + if (exactValueFlowReplyForBusinessOverviewDirectMoneyNeed) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_keep_exact_value_flow_reply_over_business_overview_direct_money_clarification" + ); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason( reasonCodes, @@ -779,6 +880,8 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !exactMatchedFactualAddressReply && !runtimeAdjustedExactReply && !runtimeMatchedExactReply && + !staleMetadataDiscoveryFallbackAgainstExactAddressReply && + !exactValueFlowReplyForBusinessOverviewDirectMoneyNeed && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts index 3321e9d..ef0ad85 100644 --- a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts @@ -96,6 +96,27 @@ describe("address follow-up temporal regressions", () => { expect(result).not.toBeNull(); expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date"); expect(result?.filters.extracted_filters.as_of_date).toBe("2026-04-16"); + expect(result?.filters.extracted_filters.counterparty).toBeUndefined(); + }); + + it("does not carry self-scope pronouns as counterparty anchors for short debt mirror follow-up", () => { + const result = runAddressDecomposeStage("а нам?", { + previous_intent: "payables_confirmed_as_of_date", + target_intent: "receivables_confirmed_as_of_date", + previous_filters: { + as_of_date: "2026-05-12", + organization: "ООО Альтернатива Плюс", + counterparty: "нас" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "нас" + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2026-05-12"); + expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс"); + expect(result?.filters.extracted_filters.counterparty).toBeUndefined(); }); it("keeps same-date inventory pivot anchored to the previous VAT date", () => { diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts index 1bb0a13..d80e779 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -56,11 +56,11 @@ describe("addressIntentResolver regression bridges", () => { expect(result.reasons).toContain("unicode_customer_concentration_bridge_signal_detected"); }); - it("defers top-year company revenue wording to business overview discovery", () => { + it("routes top-year company revenue wording to exact value-flow ranking", () => { const result = resolveAddressIntent("какой у нас самый доходный год"); - expect(result.intent).toBe("unknown"); - expect(result.reasons).toContain("unicode_business_overview_earnings_deferred_to_discovery"); + expect(result.intent).toBe("customer_revenue_and_payments"); + expect(result.reasons).toContain("unicode_customer_revenue_ranking_bridge_signal_detected"); }); it("defers paired receivables and payables wording to business overview instead of one debt side", () => { diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index cbd1fb5..21ce977 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2608,12 +2608,12 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.intent).toBe("customer_revenue_and_payments"); }); - it("defers organization-level yearly profitability wording to business overview discovery", () => { + it("routes organization-level top-year revenue wording to exact value-flow ranking", () => { const result = resolveAddressIntent( "\u043a\u0430\u043a\u0438\u0435 \u0441\u0430\u043c\u044b\u0435 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0435 \u0433\u043e\u0434\u0430 \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u044b" ); - expect(result.intent).toBe("unknown"); - expect(result.reasons).toContain("unicode_business_overview_earnings_deferred_to_discovery"); + expect(result.intent).toBe("customer_revenue_and_payments"); + expect(result.reasons).toContain("unicode_customer_revenue_ranking_bridge_signal_detected"); }); it("defers organization-level profit and margin wording to business overview discovery", () => { @@ -3496,12 +3496,10 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(reply.toLowerCase()).toContain("shortlist"); } else { expect(result?.debug.balance_confirmed).toBe(true); - expect(reply).toContain("Коротко: подтвержденный долг к оплате"); - expect(reply).toContain("Это подтвержденный срез обязательств к оплате"); - expect(reply).toMatch(/\n\nЧто учтено/u); - expect(reply).toMatch(/\n\nСводка/u); - expect(reply).toMatch(/\n\nКатегории обязательств/u); - expect(reply).toMatch(/\n\nПодтвержденные позиции к оплате/u); + expect(reply).toContain("Коротко: на 31.05.2020 мы должны"); + expect(reply).toContain("Крупнейшие позиции к оплате"); + expect(reply).toContain("Основа: подтвержденный остаток по счетам 60/76"); + expect(reply.length).toBeLessThan(1800); expect(reply).not.toContain("Блок 1"); expect(reply).not.toContain("эвристический"); } @@ -3793,17 +3791,16 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); }); - it("defers yearly profitability wording out of the exact customer value aggregate recipe", async () => { + it("routes organization-level yearly revenue ranking wording into the exact customer value aggregate recipe", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( "\u043a\u0430\u043a\u0438\u0435 \u0441\u0430\u043c\u044b\u0435 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0435 \u0433\u043e\u0434\u0430 \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u044b" ); expect(result?.handled).toBe(true); - expect(result?.debug.detected_intent).toBe("unknown"); - expect(result?.debug.selected_recipe).toBeNull(); - expect(result?.debug.limited_reason_category).toBe("unsupported"); - expect(result?.debug.mcp_call_status).toBe("skipped"); - expect(result?.response_type).toBe("LIMITED_WITH_REASON"); + expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments"); + expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type); }); it("routes typo highest-check wording into customer value aggregate recipe", async () => { diff --git a/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts index 470a289..a8f8d4a 100644 --- a/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts +++ b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts @@ -53,6 +53,7 @@ describe("receivables confirmed as-of route", () => { const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки"); expect(plan.query).toContain("СуммаРазвернутыйОстатокДт"); + expect(plan.query).toContain("СуммаРазвернутыйОстатокКт"); expect(plan.query).toContain("Остатки.Счет"); expect(plan.query).toContain("62"); expect(plan.query).toContain("76"); diff --git a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts index d06a6b5..38b439a 100644 --- a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts +++ b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { composeCounterpartyAnalyticsReply } from "../src/services/address_runtime/counterpartyAnalyticsReplyBuilders"; +import { composeFactualReply } from "../src/services/address_runtime/composeStage"; import { composeInventoryReply } from "../src/services/address_runtime/inventoryReplyBuilders"; describe("address reply builders regressions", () => { @@ -51,6 +52,88 @@ describe("address reply builders regressions", () => { expect(result?.text.split("\n")[0]).toContain("Чапурнов"); }); + it("keeps canonical single-best customer wording compact instead of returning the default top list", () => { + const result = composeCounterpartyAnalyticsReply( + "customer_revenue_and_payments", + [ + { + counterparty: "Крупный клиент", + amount: 250000, + period: "2020-03-31", + registrator: "Поступление 1" + } as any, + { + counterparty: "Малый клиент", + amount: 100000, + period: "2020-04-30", + registrator: "Поступление 2" + } as any + ], + { + userMessage: "определить самого доходного клиента за весь период" + }, + { + formatPercent: () => null, + formatDateRu: (value: string) => value, + formatMoneyRub: (value: number) => `${value} руб.`, + extractYearFromIso: (value: string | null) => (value ? Number(value.slice(0, 4)) : null), + detectCounterpartyProfileFocus: () => "full_profile", + detectCounterpartyLifecycleFocus: () => "active_customers_all_time", + hasCounterpartyLifecycleLongevityQuestion: () => false, + hasCounterpartyActivityAgeQuestion: () => false, + detectRankingLimit: () => 20, + detectValueRankingFocus: () => "top_by_total", + detectContractValueFocus: () => "top_by_turnover", + detectMinOpsForAvgCheck: () => 1, + extractRequestedYearFromQuestion: () => null, + extractCounterpartyName: (row: any) => row.counterparty, + extractContractName: () => null, + counterpartyLookupMatches: () => false, + toUtcDayTimestamp: () => null, + formatAgeYearsMonthsDays: () => "0 дней", + normalizeQuestionText: (value: string | null | undefined) => String(value ?? "").toLowerCase() + } + ); + + expect(result?.text.split("\n")[0]).toContain("Крупный клиент"); + expect(result?.text).not.toContain("2. Малый клиент"); + }); + + it("keeps direct receivables snapshot answers compact", () => { + const result = composeFactualReply( + "receivables_confirmed_as_of_date", + [ + { + period: "2020-05-10", + registrator: "Поступление 1", + account_dt: "62.01", + account_kt: "90.01", + amount: 100000, + analytics: ["Клиент А"], + counterparty: "Клиент А" + }, + { + period: "2020-05-11", + registrator: "Поступление 2", + account_dt: "62.01", + account_kt: "90.01", + amount: 50000, + analytics: ["Клиент Б"], + counterparty: "Клиент Б" + } + ], + { + userMessage: "кто нам должен денег на май 2020", + asOfDate: "2020-05-31" + } + ); + + expect(result.text.split("\n")[0]).toContain("Коротко:"); + expect(result.text.split("\n")[0]).toContain("Клиент А"); + expect(result.text.length).toBeLessThan(1200); + expect(result.text).toContain("Крупнейшие позиции к получению"); + }); + it("does not overclaim a comparative top customer ranking when only one candidate is present", () => { const result = composeCounterpartyAnalyticsReply( "customer_revenue_and_payments", @@ -190,4 +273,174 @@ describe("address reply builders regressions", () => { expect(firstLine).toContain("К самым старым закупкам"); expect(firstLine).not.toContain("2026-04-18"); }); + + it("excludes mirrored 76 settlements from clean payables", () => { + const result = composeFactualReply( + "payables_confirmed_as_of_date", + [ + { + period: "2026-05-12", + registrator: "", + account_dt: "51", + account_kt: "76.09", + amount: 3677454.14, + analytics: ["Комитет государственных услуг г. Москвы", "ООО \\Альтернатива Плюс\\", "Финансовое обеспечение заявки"], + counterparty: "Комитет государственных услуг г. Москвы", + organization: "ООО \\Альтернатива Плюс\\" + }, + { + period: "2026-05-12", + registrator: "Списание с расчетного счета 1", + account_dt: "76.09", + account_kt: "51", + amount: 1000000, + analytics: ["Комитет государственных услуг г. Москвы", "ООО \\Альтернатива Плюс\\", "Финансовое обеспечение заявки"], + counterparty: "Комитет государственных услуг г. Москвы", + organization: "ООО \\Альтернатива Плюс\\" + }, + { + period: "2026-05-12", + registrator: "Списание с расчетного счета 2", + account_dt: "76.09", + account_kt: "51", + amount: 2677454.14, + analytics: ["Комитет государственных услуг г. Москвы", "ООО \\Альтернатива Плюс\\", "Финансовое обеспечение заявки"], + counterparty: "Комитет государственных услуг г. Москвы", + organization: "ООО \\Альтернатива Плюс\\" + }, + { + period: "2026-05-12", + registrator: "Поступление товаров 1", + account_dt: "41.01", + account_kt: "60.01", + amount: 7271.2, + analytics: ["Авант мебель"], + counterparty: "Авант мебель", + organization: "ООО Альтернатива Плюс" + } + ], + { + userMessage: "мы должны кому-то денег на сегодня?", + asOfDate: "2026-05-12" + } + ); + + const firstLine = result.text.split("\n")[0] ?? ""; + expect(firstLine).toContain("7.271,20"); + expect(firstLine).toContain("Авант мебель"); + expect(firstLine).not.toContain("3.677.454,14"); + expect(result.text).toContain("встречных остатков"); + expect(result.text).toContain("Комитет государственных услуг г. Москвы"); + expect(result.text).toContain("Финансовое обеспечение заявки"); + expect(result.text).not.toContain("договор/аналитика: ООО \\Альтернатива Плюс\\"); + }); + + it("excludes mirrored 76 settlements from clean receivables", () => { + const result = composeFactualReply( + "receivables_confirmed_as_of_date", + [ + { + period: "2026-05-12", + registrator: "Реализация 1", + account_dt: "62.01", + account_kt: "90.01", + amount: 9612904.9, + analytics: ["Департамент капитального ремонта города Москвы."], + counterparty: "Департамент капитального ремонта города Москвы.", + organization: "ООО Альтернатива Плюс" + }, + { + period: "2026-05-12", + registrator: "", + account_dt: "51", + account_kt: "76.09", + amount: 3677454.14, + analytics: ["Комитет государственных услуг г. Москвы", "Финансовое обеспечение заявки"], + counterparty: "Комитет государственных услуг г. Москвы", + organization: "ООО Альтернатива Плюс" + }, + { + period: "2026-05-12", + registrator: "Списание с расчетного счета 1", + account_dt: "76.09", + account_kt: "51", + amount: 3677454.14, + analytics: ["Комитет государственных услуг г. Москвы", "Финансовое обеспечение заявки"], + counterparty: "Комитет государственных услуг г. Москвы", + organization: "ООО Альтернатива Плюс" + } + ], + { + userMessage: "а нам кто должен на сегодня?", + asOfDate: "2026-05-12" + } + ); + + const firstLine = result.text.split("\n")[0] ?? ""; + expect(firstLine).toContain("9.612.904,90"); + expect(firstLine).toContain("Департамент капитального ремонта города Москвы."); + expect(firstLine).not.toContain("13.290.359,04"); + expect(result.text).toContain("встречных остатков"); + expect(result.text).toContain("Комитет государственных услуг г. Москвы"); + }); + + it("keeps bank-named balance counterparties as counterparties, not settlement analytics", () => { + const result = composeFactualReply( + "receivables_confirmed_as_of_date", + [ + { + period: "2017-05-31", + registrator: "Остатки на дату", + account_dt: "76.09", + account_kt: null, + amount: 39079.12, + analytics: ["Сбербанк-АСТ, ЗАО", "Финансовое обеспечение заявки"], + organization: "ООО \\Альтернатива Плюс\\" + } + ], + { + userMessage: "кто нам должен денег на май 2017?", + asOfDate: "2017-05-31" + } + ); + + const firstLine = result.text.split("\n")[0] ?? ""; + expect(firstLine).toContain("Сбербанк-АСТ, ЗАО"); + expect(firstLine).not.toContain("Финансовое обеспечение заявки"); + }); + + it("keeps different 76 contracts separated instead of netting by counterparty only", () => { + const result = composeFactualReply( + "payables_confirmed_as_of_date", + [ + { + period: "2026-05-12", + registrator: "", + account_dt: "51", + account_kt: "76.09", + amount: 1000, + analytics: ["Комитет государственных услуг г. Москвы", "Договор А"], + counterparty: "Комитет государственных услуг г. Москвы", + organization: "ООО Альтернатива Плюс" + }, + { + period: "2026-05-12", + registrator: "Списание с расчетного счета 1", + account_dt: "76.09", + account_kt: "51", + amount: 1000, + analytics: ["Комитет государственных услуг г. Москвы", "Договор Б"], + counterparty: "Комитет государственных услуг г. Москвы", + organization: "ООО Альтернатива Плюс" + } + ], + { + userMessage: "мы должны кому-то денег на сегодня?", + asOfDate: "2026-05-12" + } + ); + + expect(result.text.split("\n")[0]).toContain("1.000,00"); + expect(result.text).not.toContain("встречных остатков"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 9ed7327..9fc839a 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -362,12 +362,14 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.next_step_line).toContain("прибыль/маржу"); expect(draft.must_not_claim).toContain("Do not present business overview cash-flow spread as profit or margin."); expect(draft.must_not_claim).toContain("Do not present business overview yearly operating-flow breakdown as profit, financial result, or a complete annual P&L."); - expect(draft.must_not_claim).toContain("Do not present business overview supplier concentration as vendor-risk audit, procurement quality, or full expense structure."); + expect(draft.must_not_claim).toContain("Do not present reviewed procurement concentration as supplier reliability, delivery quality, payment-purpose classification, or full expense structure."); expect(draft.must_not_claim).toContain("Do not present business overview document/account-section activity profile as process quality, accounting correctness, or completeness of all 1C activity."); expect(draft.must_not_claim).toContain("Do not present business overview counterparty or contract profile as CRM quality, counterparty due diligence, contract-risk audit, or legal completeness."); expect(draft.must_not_claim).toContain("Do not present business overview missing proof families as checked, executed, or confirmed routes."); expect(draft.reason_codes).toContain("answer_contains_business_overview"); expect(draft.reason_codes).toContain("answer_contains_business_overview_supplier_concentration"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_vendor_procurement_quality"); + expect(draft.reason_codes).toContain("answer_contains_business_overview_vendor_procurement_quality_reviewed_procurement_concentration"); expect(draft.reason_codes).toContain("answer_contains_business_overview_yearly_operating_breakdown"); expect(draft.reason_codes).toContain("answer_contains_business_overview_document_activity_profile"); expect(draft.reason_codes).toContain("answer_contains_business_overview_counterparty_profile"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index 95bbd24..f020216 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -258,20 +258,36 @@ describe("assistant MCP discovery pilot executor", () => { unused_contracts: 372, used_contract_share_pct: 28.46 }); + expect(result.derived_business_overview?.vendor_procurement_quality).toMatchObject({ + rows_with_amount: 2, + total_outgoing_amount: 200000, + top_outgoing_share_pct: 75, + top_non_financial_supplier_share_pct: 75, + financial_institution_leads_outgoing_cash: false, + supplier_only_count: 71, + mixed_role_count: 23, + used_contracts: 148, + total_contracts: 520, + used_contract_share_pct: 28.46, + evidence_status: "reviewed_procurement_concentration", + inference_basis: "supplier_payout_concentration_counterparty_contract_profile_confirmed_1c_rows" + }); + expect(result.derived_business_overview?.vendor_procurement_quality?.top_outgoing_counterparty).toMatchObject({ + axis_value: "Поставщик А", + total_amount: 150000 + }); expect(result.derived_business_overview?.missing_proof_families).toEqual( expect.arrayContaining([ expect.objectContaining({ family: "accounting_profit_margin", current_status: "reviewed_route_not_wired", current_supported_evidence: null - }), - expect.objectContaining({ - family: "vendor_risk_procurement_quality", - current_status: "proxy_only_currently", - current_supported_evidence: "supplier_concentration_proxy_from_confirmed_outgoing_payment_rows" }) ]) ); + expect(result.derived_business_overview?.missing_proof_families.map((item) => item.family)).not.toContain( + "vendor_risk_procurement_quality" + ); expect(result.evidence.confirmed_facts.join("\n")).toContain("В 1С подтверждены входящие поступления"); expect(result.evidence.confirmed_facts.join("\n")).toContain("Самый крупный подтвержденный поставщик"); expect(result.evidence.confirmed_facts.join("\n")).toContain("Годовая раскладка операционного денежного потока"); @@ -289,6 +305,8 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.reason_codes).toContain("pilot_derived_business_overview_document_activity_profile_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_counterparty_profile_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_contract_usage_profile_from_confirmed_rows"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_vendor_procurement_quality_from_confirmed_rows"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_vendor_procurement_quality_reviewed_procurement_concentration"); expect(result.reason_codes).toContain("pilot_business_overview_missing_proof_families_recorded"); expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(6); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index 6290129..2b80841 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -904,6 +904,40 @@ describe("assistant MCP discovery planner", () => { expect(result.selected_chain_id).not.toBe("entity_resolution"); }); + it("keeps ranked value-flow when subject candidates only scope the ranking question", () => { + const result = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: ["Альтернатива Плюс"], + business_fact_family: "value_flow", + action_family: "turnover", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: "top_desc", + proof_expectation: "coverage_checked_fact", + clarification_gaps: [], + decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "all_time", + explicit_organization_scope: "Альтернатива Плюс" + } + }); + + expect(result.planner_status).toBe("ready_for_execution"); + expect(result.selected_chain_id).toBe("value_flow_ranking"); + expect(result.catalog_chain_template_matches[0]).toBe("value_flow_ranking"); + expect(result.catalog_chain_template_alignment.selected_chain_matches_top).toBe(true); + expect(result.reason_codes).toContain("planner_selected_top_ranked_value_flow_from_data_need_graph"); + expect(result.reason_codes).toContain("planner_kept_ranking_over_subject_scope_candidates"); + }); + it("keeps ranked value-flow ready for execution once checked period and organization are known", () => { const result = planAssistantMcpDiscovery({ dataNeedGraph: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 52a0737..530f9b6 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -215,6 +215,95 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("операционное нетто"); }); + it("uses reviewed procurement-concentration evidence for vendor-risk boundary answers", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "vendor_risk_procurement_boundary", + unsupported_but_understood_family: "vendor_risk_procurement_boundary" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + period_scope: "2020", + outgoing_supplier_payout: { + total_amount_human_ru: "1 000 000 руб." + }, + top_suppliers: [ + { + axis_value: "СБЕРБАНК, ПАО", + total_amount_human_ru: "700 000 руб." + } + ], + vendor_procurement_quality: { + period_scope: "2020", + rows_with_amount: 2, + total_outgoing_amount: 1000000, + total_outgoing_amount_human_ru: "1 000 000 руб.", + top_outgoing_counterparty: { + axis_value: "СБЕРБАНК, ПАО", + total_amount: 700000, + total_amount_human_ru: "700 000 руб.", + rows_with_amount: 1, + share_pct: 70, + is_likely_financial_institution: true + }, + top_outgoing_share_pct: 70, + top_non_financial_supplier: { + axis_value: "Поставщик А", + total_amount: 300000, + total_amount_human_ru: "300 000 руб.", + rows_with_amount: 1, + share_pct: 30, + is_likely_financial_institution: false + }, + top_non_financial_supplier_share_pct: 30, + financial_institution_leads_outgoing_cash: true, + supplier_only_count: null, + mixed_role_count: null, + used_contracts: null, + total_contracts: null, + used_contract_share_pct: null, + evidence_status: "financial_institution_leads_outgoing_cash", + inference_basis: "supplier_payout_concentration_counterparty_contract_profile_confirmed_1c_rows" + } + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Vendor route.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("procurement-concentration route"); + expect(candidate.reply_text).toContain("банк/финансовая организация"); + expect(candidate.reply_text).toContain("Поставщик А"); + expect(candidate.reply_text).toContain("надежность поставщика"); + expect(candidate.reply_text).not.toContain("outgoing cash concentration proxy"); + expect(candidate.reply_text).not.toContain("business_overview_route_template_v1"); + }); + it("uses a compact direct answer for business-overview top year questions", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index cf692a7..90dc2e2 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -367,6 +367,83 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); }); + it("keeps exact receivables replies when metadata inspection is only a stale fallback candidate", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Коротко: подтвержденная дебиторская задолженность на 31.05.2017 — 1 234 567,89 руб.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "receivables_confirmed_as_of_date", + selected_recipe: "address_receivables_confirmed_as_of_date_v1", + mcp_call_status: "matched_non_empty", + truth_mode: "confirmed", + capability_binding_status: "bound", + capability_binding_violations: [], + answer_shape_contract: { + reply_type: "factual", + capability_contract_id: "confirmed_receivables_as_of_date" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + data_need_graph: { + business_fact_family: null, + action_family: "confirmed_snapshot", + clarification_gaps: ["subject"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_family_unknown", + "data_need_graph_has_clarification_gaps" + ] + }, + turn_meaning_ref: { + asked_domain_family: "receivables", + asked_action_family: "confirmed_snapshot", + explicit_date_scope: "2017-05-31", + unsupported_but_understood_family: "profit_margin_boundary", + stale_replay_forbidden: true + }, + reason_codes: [ + "mcp_discovery_seeded_from_followup_context", + "mcp_discovery_resumed_from_saved_loop_state", + "mcp_discovery_data_need_graph_built" + ] + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "metadata_inspection_v1" + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Metadata surface confirmed.", + confirmed_lines: ["Available metadata object sets: Document, AccumulationRegister"], + inference_lines: [], + unknown_lines: ["Detailed metadata fields were not returned by this probe."], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toContain("дебиторская задолженность"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_keep_exact_address_reply_over_stale_metadata_discovery" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_metadata_candidate_priority"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied"); + }); + it("keeps aligned factual address lane answers when the exact lane already matched the same semantic intent", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "ИП Калинин Н.М. | сумма: 216600 | операций: 2", @@ -582,6 +659,71 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); }); + it("keeps exact top-year value-flow replies over business-overview direct-money clarification", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Самый доходный год по подтвержденным поступлениям: 2020 (15 744 052,48 руб. по 20 операциям). Это денежный поток, а не чистая прибыль.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + selected_recipe: "address_customer_revenue_and_payments_v1", + mcp_call_status: "matched_non_empty", + truth_mode: "confirmed", + capability_binding_status: "bound", + capability_binding_violations: [], + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + data_need_graph: { + business_fact_family: "business_overview", + action_family: "profit_margin_boundary", + ranking_need: "top_desc", + clarification_gaps: ["organization"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_family_business_overview", + "data_need_graph_ranking_top_desc", + "data_need_graph_business_overview_direct_money_answer", + "data_need_graph_open_scope_total_needs_organization" + ] + }, + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "profit_margin_boundary", + unsupported_but_understood_family: "profit_margin_boundary", + stale_replay_forbidden: true + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "Нужно уточнить организацию.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: ["Без организации бизнес-обзор не запускаю."], + limitation_lines: [], + next_step_line: "Уточните организацию." + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toContain("Самый доходный год"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_keep_exact_value_flow_reply_over_business_overview_direct_money_clarification" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied"); + }); + it("overrides an exact ranking-shaped address reply when bounded open-scope ranking already has organization and period", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index 7784d13..a88942f 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -757,6 +757,107 @@ describe("assistant MCP discovery runtime bridge", () => { expect(result.reason_codes).toContain("answer_contains_business_overview_debt_due_date_aging_no_payment_terms_configured"); }); + it("promotes vendor-risk boundary through reviewed procurement concentration evidence", async () => { + const deps = buildSequentialDeps([ + { rows: [] }, + { + rows: [ + { Period: "2020-01-20T00:00:00", Amount: 700000, Counterparty: "СБЕРБАНК, ПАО" }, + { Period: "2020-02-20T00:00:00", Amount: 300000, Counterparty: "Поставщик А" } + ] + }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-01-01T00:00:00", Registrator: "CP_TOTAL", Amount: 12 }, + { Period: "2020-01-01T00:00:00", Registrator: "CP_SUPPLIER_ACTIVE", Amount: 5 }, + { Period: "2020-01-01T00:00:00", Registrator: "CP_MIXED_ACTIVE", Amount: 2 }, + { Period: "2020-01-01T00:00:00", Registrator: "CP_ACTIVE_UNION", Amount: 7 } + ] + }, + { + rows: [ + { Period: "2020-01-01T00:00:00", Registrator: "CT_TOTAL", Amount: 11 }, + { Period: "2020-01-01T00:00:00", Registrator: "CT_USED", Amount: 4 } + ] + } + ]); + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "vendor_risk_procurement_boundary", + aggregation_need: null, + time_scope_need: "all_time_scope", + comparison_need: null, + ranking_need: null, + proof_expectation: "vendor_risk_procurement_quality", + 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_unchecked_vendor_reliability_claim" + ], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "vendor_risk_procurement_boundary", + explicit_organization_scope: "OOO Alternative Plus" + }, + deps + }); + const overview = result.pilot.derived_business_overview; + const missingFamilies = overview?.missing_proof_families.map((item) => item.family) ?? []; + const userFacing = [ + result.answer_draft.headline, + ...result.answer_draft.confirmed_lines, + ...result.answer_draft.inference_lines, + ...result.answer_draft.unknown_lines, + ...result.answer_draft.limitation_lines, + result.answer_draft.next_step_line ?? "" + ].join("\n"); + + expect(result.bridge_status).toBe("answer_draft_ready"); + expect(result.business_fact_answer_allowed).toBe(true); + expect(result.route_candidate).toMatchObject({ + candidate_status: "ready_for_reviewed_execution", + selected_chain_id: "business_overview", + business_fact_family: "business_overview", + action_family: "vendor_risk_procurement_boundary", + executable_now: true + }); + expect(overview?.vendor_procurement_quality).toMatchObject({ + total_outgoing_amount: 1000000, + top_outgoing_share_pct: 70, + top_non_financial_supplier_share_pct: 30, + financial_institution_leads_outgoing_cash: true, + supplier_only_count: 3, + mixed_role_count: 2, + used_contracts: 4, + total_contracts: 11, + evidence_status: "financial_institution_leads_outgoing_cash" + }); + expect(missingFamilies).not.toContain("vendor_risk_procurement_quality"); + expect(userFacing).toContain("procurement-concentration route"); + expect(userFacing).toContain("банк/финансовая организация"); + expect(userFacing).toContain("Поставщик А"); + expect(userFacing).toContain("Надежность поставщиков"); + expect(userFacing).not.toContain("outgoing cash concentration proxy"); + expect(result.reason_codes).toContain("runtime_bridge_route_candidate_ready_for_reviewed_execution"); + expect(result.reason_codes).toContain("answer_contains_business_overview_vendor_procurement_quality_financial_institution_leads_outgoing_cash"); + }); + it("bridges selected-item inventory provenance templates through exact document evidence", async () => { const deps = buildDeps([ { diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index 62d956f..e81957a 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -1,4 +1,109 @@ [ + { + "generation_id": "gen-ag05121628-50ea6c", + "created_at": "2026-05-12T16:28:41+00:00", + "mode": "saved_user_sessions", + "title": "AGENT | Phase20 continuity/ranking/self-scope accepted replay 2026-05-12", + "count": 6, + "domain": "address_phase20_continuity_stabilization", + "questions": [ + "кто у нас самый доходный клиент за все время?", + "какой у нас самый доходный год?", + "кто нам должен денег на май 2017?", + "а какой ндс мы должны примерно заплатить за этот период?", + "мы должны комуто денег на сегодня?", + "а нам?" + ], + "generated_by": "Codex", + "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260512162841_gen-ag05121628-50ea6c.json", + "context": { + "llm_provider": null, + "model": null, + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "autogen_personality_id": null, + "autogen_personality_prompt": null, + "source_session_id": null, + "saved_session_file": "assistant_saved_session_20260512162841_gen-ag05121628-50ea6c.json", + "saved_case_set_kind": "agent_semantic_scenario", + "agent_run": true, + "agent_focus": "ranking catalog alignment, self-scope pronoun, compact debt answers", + "architecture_phase": "MCP автономка / semantic integrity", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase20_continuity_stabilization.json", + "scenario_id": "address_truth_harness_phase20_continuity_stabilization", + "semantic_tags": [ + "exact_not_overwritten", + "garbage_anchor_forbidden", + "payables_snapshot", + "period_carryover", + "pronoun_followup", + "receivables_snapshot", + "temporal_tail_not_entity", + "value_flow_ranking", + "vat_followup", + "year_tail_not_entity" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase20_continuity_stabilization_rerun_planner_ranking_fix", + "saved_after_validated_replay": true + } + }, + { + "generation_id": "gen-ag05121357-9ea5d6", + "created_at": "2026-05-12T13:57:26+00:00", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 95 vendor/procurement quality reviewed route", + "count": 7, + "domain": "address_phase95_vendor_procurement_quality_reviewed_route", + "questions": [ + "по ООО Альтернатива Плюс за 2020 есть ли риск, что мы зависим от одного поставщика?", + "а банк из этого ответа считать обычным поставщиком?", + "по этой же компании на конец 2020 можно точно понять, какая дебиторка просрочена?", + "а чистая прибыль и маржа за 2020 какие?", + "НДС за 2020 по ООО Альтернатива Плюс какой?", + "а кто принес больше всего денег за 2020?", + "по ООО Альтернатива Плюс на конец 2020 можно точно подтвердить резерв под неликвиды на складе?" + ], + "generated_by": "codex_agent", + "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260512135726_gen-ag05121357-9ea5d6.json", + "context": { + "llm_provider": null, + "model": null, + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "autogen_personality_id": null, + "autogen_personality_prompt": null, + "source_session_id": null, + "saved_session_file": "assistant_saved_session_20260512135726_gen-ag05121357-9ea5d6.json", + "saved_case_set_kind": "agent_semantic_scenario", + "agent_run": true, + "agent_focus": "Focused semantic replay for promoting vendor_risk_procurement_quality from proxy-only enablement to reviewed procurement-concentration evidence while preserving debt, profit, VAT, value-flow, and inventory reserve canaries.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase95_vendor_procurement_quality_reviewed_route.json", + "scenario_id": "address_truth_harness_phase95_vendor_procurement_quality_reviewed_route", + "semantic_tags": [ + "accounting_profit_margin", + "business_overview", + "canary", + "context_carryover", + "debt_due_date_boundary", + "financial_institution_boundary", + "inventory_reserve_boundary", + "missing_proof_families", + "profit_margin_boundary", + "ready_for_reviewed_execution", + "value_flow_ranking", + "vat_continuity", + "vendor_risk_procurement_boundary", + "vendor_risk_procurement_quality" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase95_vendor_procurement_quality_reviewed_route_live2", + "saved_after_validated_replay": true + } + }, { "generation_id": "gen-ag05101319-c04f79", "created_at": "2026-05-10T13:19:22+00:00", @@ -1392,4 +1497,4 @@ "latest_acceptance": null } } -] \ No newline at end of file +] diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512135726_gen-ag05121357-9ea5d6.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512135726_gen-ag05121357-9ea5d6.json new file mode 100644 index 0000000..6ce23d7 --- /dev/null +++ b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512135726_gen-ag05121357-9ea5d6.json @@ -0,0 +1,173 @@ +{ + "saved_at": "2026-05-12T13:57:26+00:00", + "generation_id": "gen-ag05121357-9ea5d6", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 95 vendor/procurement quality reviewed route", + "agent_run": true, + "questions": [ + "по ООО Альтернатива Плюс за 2020 есть ли риск, что мы зависим от одного поставщика?", + "а банк из этого ответа считать обычным поставщиком?", + "по этой же компании на конец 2020 можно точно понять, какая дебиторка просрочена?", + "а чистая прибыль и маржа за 2020 какие?", + "НДС за 2020 по ООО Альтернатива Плюс какой?", + "а кто принес больше всего денег за 2020?", + "по ООО Альтернатива Плюс на конец 2020 можно точно подтвердить резерв под неликвиды на складе?" + ], + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Focused semantic replay for promoting vendor_risk_procurement_quality from proxy-only enablement to reviewed procurement-concentration evidence while preserving debt, profit, VAT, value-flow, and inventory reserve canaries.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase95_vendor_procurement_quality_reviewed_route.json", + "scenario_id": "address_truth_harness_phase95_vendor_procurement_quality_reviewed_route", + "semantic_tags": [ + "accounting_profit_margin", + "business_overview", + "canary", + "context_carryover", + "debt_due_date_boundary", + "financial_institution_boundary", + "inventory_reserve_boundary", + "missing_proof_families", + "profit_margin_boundary", + "ready_for_reviewed_execution", + "value_flow_ranking", + "vat_continuity", + "vendor_risk_procurement_boundary", + "vendor_risk_procurement_quality" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase95_vendor_procurement_quality_reviewed_route_live2", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase95_vendor_procurement_quality_reviewed_route_live2", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 7, + "steps_passed": 7, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "saved_after_validated_replay": true + } + }, + "source_session_id": null, + "session": { + "session_id": null, + "mode": "agent_semantic_run", + "items": [ + { + "message_id": "agent-user-001", + "role": "user", + "text": "по ООО Альтернатива Плюс за 2020 есть ли риск, что мы зависим от одного поставщика?", + "created_at": "2026-05-12T13:57:26+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-002", + "role": "user", + "text": "а банк из этого ответа считать обычным поставщиком?", + "created_at": "2026-05-12T13:57:26+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-003", + "role": "user", + "text": "по этой же компании на конец 2020 можно точно понять, какая дебиторка просрочена?", + "created_at": "2026-05-12T13:57:26+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-004", + "role": "user", + "text": "а чистая прибыль и маржа за 2020 какие?", + "created_at": "2026-05-12T13:57:26+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-005", + "role": "user", + "text": "НДС за 2020 по ООО Альтернатива Плюс какой?", + "created_at": "2026-05-12T13:57:26+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-006", + "role": "user", + "text": "а кто принес больше всего денег за 2020?", + "created_at": "2026-05-12T13:57:26+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-007", + "role": "user", + "text": "по ООО Альтернатива Плюс на конец 2020 можно точно подтвердить резерв под неликвиды на складе?", + "created_at": "2026-05-12T13:57:26+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + } + ], + "agent_run": true, + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Focused semantic replay for promoting vendor_risk_procurement_quality from proxy-only enablement to reviewed procurement-concentration evidence while preserving debt, profit, VAT, value-flow, and inventory reserve canaries.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase95_vendor_procurement_quality_reviewed_route.json", + "scenario_id": "address_truth_harness_phase95_vendor_procurement_quality_reviewed_route", + "semantic_tags": [ + "accounting_profit_margin", + "business_overview", + "canary", + "context_carryover", + "debt_due_date_boundary", + "financial_institution_boundary", + "inventory_reserve_boundary", + "missing_proof_families", + "profit_margin_boundary", + "ready_for_reviewed_execution", + "value_flow_ranking", + "vat_continuity", + "vendor_risk_procurement_boundary", + "vendor_risk_procurement_quality" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase95_vendor_procurement_quality_reviewed_route_live2", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase95_vendor_procurement_quality_reviewed_route_live2", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 7, + "steps_passed": 7, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "saved_after_validated_replay": true + } + } + } +} diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512162841_gen-ag05121628-50ea6c.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512162841_gen-ag05121628-50ea6c.json new file mode 100644 index 0000000..474f604 --- /dev/null +++ b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512162841_gen-ag05121628-50ea6c.json @@ -0,0 +1,155 @@ +{ + "saved_at": "2026-05-12T16:28:41+00:00", + "generation_id": "gen-ag05121628-50ea6c", + "mode": "saved_user_sessions", + "title": "AGENT | Phase20 continuity/ranking/self-scope accepted replay 2026-05-12", + "agent_run": true, + "questions": [ + "кто у нас самый доходный клиент за все время?", + "какой у нас самый доходный год?", + "кто нам должен денег на май 2017?", + "а какой ндс мы должны примерно заплатить за этот период?", + "мы должны комуто денег на сегодня?", + "а нам?" + ], + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "ranking catalog alignment, self-scope pronoun, compact debt answers", + "architecture_phase": "MCP автономка / semantic integrity", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase20_continuity_stabilization.json", + "scenario_id": "address_truth_harness_phase20_continuity_stabilization", + "semantic_tags": [ + "exact_not_overwritten", + "garbage_anchor_forbidden", + "payables_snapshot", + "period_carryover", + "pronoun_followup", + "receivables_snapshot", + "temporal_tail_not_entity", + "value_flow_ranking", + "vat_followup", + "year_tail_not_entity" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase20_continuity_stabilization_rerun_planner_ranking_fix", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase20_continuity_stabilization_rerun_planner_ranking_fix", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 6, + "steps_passed": 6, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "saved_after_validated_replay": true + } + }, + "source_session_id": null, + "session": { + "session_id": null, + "mode": "agent_semantic_run", + "items": [ + { + "message_id": "agent-user-001", + "role": "user", + "text": "кто у нас самый доходный клиент за все время?", + "created_at": "2026-05-12T16:28:41+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-002", + "role": "user", + "text": "какой у нас самый доходный год?", + "created_at": "2026-05-12T16:28:41+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-003", + "role": "user", + "text": "кто нам должен денег на май 2017?", + "created_at": "2026-05-12T16:28:41+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-004", + "role": "user", + "text": "а какой ндс мы должны примерно заплатить за этот период?", + "created_at": "2026-05-12T16:28:41+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-005", + "role": "user", + "text": "мы должны комуто денег на сегодня?", + "created_at": "2026-05-12T16:28:41+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-006", + "role": "user", + "text": "а нам?", + "created_at": "2026-05-12T16:28:41+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + } + ], + "agent_run": true, + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "ranking catalog alignment, self-scope pronoun, compact debt answers", + "architecture_phase": "MCP автономка / semantic integrity", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase20_continuity_stabilization.json", + "scenario_id": "address_truth_harness_phase20_continuity_stabilization", + "semantic_tags": [ + "exact_not_overwritten", + "garbage_anchor_forbidden", + "payables_snapshot", + "period_carryover", + "pronoun_followup", + "receivables_snapshot", + "temporal_tail_not_entity", + "value_flow_ranking", + "vat_followup", + "year_tail_not_entity" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase20_continuity_stabilization_rerun_planner_ranking_fix", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase20_continuity_stabilization_rerun_planner_ranking_fix", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 6, + "steps_passed": 6, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "saved_after_validated_replay": true + } + } + } +} diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512135726_gen-ag05121357-9ea5d6.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512135726_gen-ag05121357-9ea5d6.json new file mode 100644 index 0000000..2797a3d --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512135726_gen-ag05121357-9ea5d6.json @@ -0,0 +1,46 @@ +{ + "suite_id": "assistant_saved_session_gen-ag05121357-9ea5d6", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_suite_v0_1", + "generated_at": "2026-05-12T13:57:26+00:00", + "generation_id": "gen-ag05121357-9ea5d6", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 95 vendor/procurement quality reviewed route", + "domain": "address_phase95_vendor_procurement_quality_reviewed_route", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "agent_saved_user_sessions", + "title": "AGENT | Phase 95 vendor/procurement quality reviewed route", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "по ООО Альтернатива Плюс за 2020 есть ли риск, что мы зависим от одного поставщика?" + }, + { + "user_message": "а банк из этого ответа считать обычным поставщиком?" + }, + { + "user_message": "по этой же компании на конец 2020 можно точно понять, какая дебиторка просрочена?" + }, + { + "user_message": "а чистая прибыль и маржа за 2020 какие?" + }, + { + "user_message": "НДС за 2020 по ООО Альтернатива Плюс какой?" + }, + { + "user_message": "а кто принес больше всего денег за 2020?" + }, + { + "user_message": "по ООО Альтернатива Плюс на конец 2020 можно точно подтвердить резерв под неликвиды на складе?" + } + ] + } + ] +} diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512162841_gen-ag05121628-50ea6c.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512162841_gen-ag05121628-50ea6c.json new file mode 100644 index 0000000..1be5d5f --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512162841_gen-ag05121628-50ea6c.json @@ -0,0 +1,43 @@ +{ + "suite_id": "assistant_saved_session_gen-ag05121628-50ea6c", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_suite_v0_1", + "generated_at": "2026-05-12T16:28:41+00:00", + "generation_id": "gen-ag05121628-50ea6c", + "mode": "saved_user_sessions", + "title": "AGENT | Phase20 continuity/ranking/self-scope accepted replay 2026-05-12", + "domain": "address_phase20_continuity_stabilization", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "agent_saved_user_sessions", + "title": "AGENT | Phase20 continuity/ranking/self-scope accepted replay 2026-05-12", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "кто у нас самый доходный клиент за все время?" + }, + { + "user_message": "какой у нас самый доходный год?" + }, + { + "user_message": "кто нам должен денег на май 2017?" + }, + { + "user_message": "а какой ндс мы должны примерно заплатить за этот период?" + }, + { + "user_message": "мы должны комуто денег на сегодня?" + }, + { + "user_message": "а нам?" + } + ] + } + ] +}