ARCH: ввести data-need graph и довести open-scope comparison до live replay

This commit is contained in:
dctouch 2026-04-22 20:38:36 +03:00
parent dca49ef4e1
commit f2bd2dfdb1
27 changed files with 2832 additions and 43 deletions

View File

@ -199,3 +199,50 @@ The next move is larger:
- make the assistant able to look into 1C through bounded MCP discovery,
- choose its path through reviewed primitives,
- and answer from proved evidence instead of memorized route scripts.
## Status Update - 2026-04-22
The reset above is no longer only directional.
Its first three large blocks are now considered closed:
- `Big Block A. Metadata-First Self-Navigation`
- `Big Block B. Entity And Schema Grounding`
- `Big Block C. Planner-Selected Primitive Chains`
What is now materially real in code and replay-backed:
- metadata wording can start a live `inspect_1c_metadata` discovery path instead of collapsing into a hand-written route;
- metadata ambiguity can be held as a bounded clarification and resumed into `documents` or `movements`;
- grounded entity resolution can continue into `incoming`, `payout`, `net`, `documents`, and `movements` without repeating the entity name on every turn;
- planner-selected chains now survive year-switch, lane-switch, and direct follow-up pivots under the guarded evidence gate.
Current replay anchors for this closure:
- `address_truth_harness_phase24_metadata_lane_choice_loop_live_rerun5` accepted;
- `address_truth_harness_phase25_entity_resolution_chain_live_rerun_full_chain` accepted;
- `address_truth_harness_phase32_planner_selected_chain_end_to_end_live_rerun2` accepted `6/6`.
What is **not** complete yet:
- a general open-world understanding layer for structurally new 1C questions;
- planner selection from a broader primitive search space without pre-reviewed family scaffolding;
- a multi-hop evidence loop that can keep exploring, pause for clarification, and resume on unfamiliar contours.
That means the reset succeeded at building the bounded autonomy foundation.
It does **not** yet mean:
- unrestricted navigation through arbitrary 1C schemas;
- no-more-domain-thinking;
- "ask anything about 1C and the system will figure it out".
The next mainline now moves to:
- [16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md](./16%20-%20data_need_graph_and_open_world_mcp_plan_2026-04-22.md)
That document formalizes the next three blocks:
- `D. Question -> Data Need Graph`
- `E. Dynamic Schema Traversal And Primitive Search`
- `F. Multi-Hop Evidence Loop`

View File

@ -0,0 +1,223 @@
# 16 - Data Need Graph And Open-World MCP Plan (2026-04-22)
## Purpose
This note opens the next architecture phase after the completion of bounded-autonomy foundation blocks `A/B/C`.
It is not a restart.
It is the formal hand-off from:
- metadata-first self-navigation;
- entity/schema grounding;
- planner-selected primitive chains;
to the next problem:
- how the assistant starts understanding structurally new 1C questions without waiting for one more domain-specific route.
## Baseline Entering This Phase
The following is now treated as implemented baseline rather than future intent:
- live metadata inspection through reviewed MCP primitives;
- bounded schema grounding with honest ambiguity handling;
- planner-selected reviewed chains across entity resolution, value flow, documents, and movements;
- continuity that can preserve grounded entity and period context across the validated chain families.
Replay-backed anchors for that baseline include:
- `address_truth_harness_phase24_metadata_lane_choice_loop_live_rerun5`;
- `address_truth_harness_phase25_entity_resolution_chain_live_rerun_full_chain`;
- `address_truth_harness_phase32_planner_selected_chain_end_to_end_live_rerun2`.
This is enough to say:
- the assistant is no longer only a deterministic route bundle;
- the system already has a bounded MCP discovery substrate.
This is **not** enough to say:
- the assistant already understands arbitrary 1C questions;
- the planner already walks open-world 1C structure on its own;
- the system can already derive the correct probe sequence for unfamiliar asks without more architecture.
## Main Remaining Gap
The current system still understands many turns through a reviewed family scaffold.
That scaffold is much healthier than old route hardcoding, but it is still a scaffold.
The next large gap is:
- new questions must stop depending on whether their wording already resembles one of the preworked families;
- the runtime must first understand the user's data need as a machine-readable object;
- then search for a safe path through the MCP primitive catalog and observed 1C surface;
- then keep iterating until the answer is either proved, honestly bounded, or blocked for a concrete reason.
In short:
- move from `reviewed chain families`
- toward `open-world bounded evidence planning`.
## Big Block D. Question -> Data Need Graph
### Goal
Create one runtime object that represents the user's business data need independently from any one preworked domain route.
### Why This Block Comes First
Without this layer the planner still starts too low:
- it knows primitive families;
- it knows reviewed chains;
- but it does not yet have one explicit object saying what kind of business fact the user is asking to prove.
That means unfamiliar wording still risks falling back into:
- a nearby known family;
- a shallow unsupported verdict;
- or a hand-added route patch.
### Target Object
`assistant_data_need_graph_v1`
### Minimum Axes
- `subject_candidates`
- `business_fact_family`
- `action_family`
- `aggregation_need`
- `time_scope_need`
- `comparison_need`
- `ranking_need`
- `proof_expectation`
- `clarification_gaps`
- `decomposition_candidates`
- `forbidden_overclaim_flags`
### Scope
- normalize user wording into a business-meaning graph before primitive selection;
- distinguish direct fact asks from ranking, comparison, trend, list, drilldown, and explanation asks;
- carry explicit versus inherited axes separately;
- represent "understood but not yet grounded" without forcing a wrong domain route;
- give the planner one shared contract that survives wording variation.
### Acceptance
- structurally similar questions with different wording build equivalent data-need graphs;
- unfamiliar but intelligible questions stop collapsing into nearest-route guessing;
- the planner consumes the graph rather than ad hoc route-only hints;
- unsupported cases become "understood but missing evidence path", not "question not understood".
## Big Block E. Dynamic Schema Traversal And Primitive Search
### Goal
Teach the planner to search a broader reviewed primitive space against the observed 1C surface instead of depending on a fixed set of precomposed chain families.
### Why This Block Matters
Today the system can chain reviewed primitives well inside validated families.
What it still cannot do broadly enough is:
- look at the current data-need graph;
- inspect the schema surface;
- score plausible objects and primitives;
- assemble the next safe probe path dynamically.
That is the step from `good bounded recipes` to `bounded open-world navigation`.
### Scope
- introduce machine-readable primitive capability descriptors and prerequisites;
- rank candidate schema objects against the data-need graph;
- score document/register/catalog surfaces dynamically;
- let the planner build candidate chains from the catalog instead of picking from a short reviewed list only;
- preserve ambiguity when the schema does not yet justify a single path;
- keep traversal bounded by budget, proof rules, and reviewed primitive availability.
### Acceptance
- the planner can assemble a safe next chain without a dedicated per-family recipe;
- new questions can move from metadata into plausible schema traversal without route-per-wording work;
- ambiguity is retained honestly when multiple schema paths compete;
- no chain may bypass the evidence gate or invent unsupported primitives.
## Big Block F. Multi-Hop Evidence Loop And Clarifying Recovery
### Goal
Turn one-shot chain execution into a bounded evidence loop that can probe, detect missing axes, ask for clarification, resume, and stop honestly.
### Why This Block Is Separate
Even with a strong data-need graph and better primitive search, the assistant will still fail on unfamiliar asks if it cannot keep iterating safely.
Open-world bounded autonomy is not one magic route choice.
It is:
- execute;
- inspect what is still missing;
- clarify if needed;
- resume the same proof path;
- answer only with the evidence that survived the loop.
### Scope
- multi-step probe execution with explicit stop conditions;
- gap detection for missing organization, period, subject, or surface choice;
- clarification turns that preserve the active data-need graph and current probe state;
- resume logic that continues the same evidence loop rather than restarting from scratch;
- final answer shaping that separates:
- proved;
- inferred from evidence;
- still unknown.
### Acceptance
- a new question can survive more than one evidence hop without losing its meaning;
- clarification resumes the same proof path instead of resetting into local heuristics;
- the assistant can explain what is proved, what is inferred, and what remains unproved;
- unfamiliar asks no longer fail just because the first probe was incomplete.
## Execution Order
The next module should now be executed in this order:
1. `D. Question -> Data Need Graph`
2. `E. Dynamic Schema Traversal And Primitive Search`
3. `F. Multi-Hop Evidence Loop And Clarifying Recovery`
The order matters.
Doing `E` without `D` would create smarter probing without a stable representation of the user ask.
Doing `F` before `D/E` would create a more complex loop still attached to narrow reviewed families.
## Non-Goals
This phase should **not** be implemented as:
- another wave of domain-by-domain route stitching;
- a hidden prompt wrapper that pretends to be agentic;
- unrestricted free exploration of 1C without reviewed primitive boundaries;
- answer-time improvisation that bypasses the evidence gate;
- a stealth rewrite of the whole orchestration stack.
## Success Condition
This phase is successful only when a new human user can ask a structurally new but intelligible 1C data question and the assistant can:
1. understand the data need in machine-readable form;
2. search for a safe path through reviewed MCP primitives and observed schema surface;
3. iterate through bounded evidence steps;
4. ask a bounded clarification when needed;
5. answer honestly from proved evidence without pretending certainty it does not have.
That is the first point where the assistant will start to feel like it can actually walk 1C on its own within the reviewed MCP boundaries.

View File

@ -32,38 +32,46 @@ This package answers the next question:
12. [12 - manual_run_system_analysis_3NilqwT1G2_2026-04-18.md](./12%20-%20manual_run_system_analysis_3NilqwT1G2_2026-04-18.md)
13. [13 - pre_multidomain_readiness_audit_2026-04-18.md](./13%20-%20pre_multidomain_readiness_audit_2026-04-18.md)
14. [14 - semantic_dialog_authority_recovery_plan_2026-04-19.md](./14%20-%20semantic_dialog_authority_recovery_plan_2026-04-19.md)
15. [15 - mcp_bounded_autonomy_reset_plan_2026-04-21.md](./15%20-%20mcp_bounded_autonomy_reset_plan_2026-04-21.md)
16. [16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md](./16%20-%20data_need_graph_and_open_world_mcp_plan_2026-04-22.md)
## Current Status Snapshot (2026-04-19)
## Current Status Snapshot (2026-04-22)
This package is no longer planning-only.
It now documents a turnaround that is already operational in code, already materially past the acute regression breakpoint, but still not ready for wide multi-domain expansion:
It now documents a turnaround that is already operational in code, already materially past the acute regression breakpoint, and already moved into bounded MCP autonomy work beyond the first stabilization wave:
- route, transition, boundary, meta, memory, and provider policy owners exist as separate modules;
- exact-lane truth and coverage/evidence contracts exist as explicit runtime artifacts;
- scenario acceptance writes machine-readable `scenario_acceptance_matrix.json` and `pack_state.json`;
- AGENT semantic packs and source catalogs already exist for mixed domain/meta validation.
- the reset toward `MCP-first bounded autonomy` is now formalized;
- `Big Block A/B/C` of that reset are now closed in runtime code and replay-backed;
- the next architecture mainline is no longer continuity polishing, but `D/E/F`:
- `Question -> Data Need Graph`
- dynamic schema traversal and primitive search
- multi-hop evidence loop with bounded clarification recovery
Current honest status:
- turnaround implementation progress: `~96%`
- exit-from-danger-zone readiness: `~91%`
- pre-multidomain readiness: `~78%`
- graph snapshot after latest rebuild: `5372 nodes`, `11525 edges`, `135 communities`
- bounded-autonomy foundation readiness: `~60%`
- graph snapshot after latest rebuild: `5741 nodes`, `12385 edges`, `137 communities`
- current breakpoint:
- the validated hot paths are no longer structurally broken;
- flagship continuity collapse is no longer the primary risk;
- the main remaining risk is no longer clarification-resume collapse, but the unfinished final convergence toward one true runtime authority plus replay breadth still below the intended multi-domain blast radius;
- pure wording polish is now secondary debt, but semantic robustness of user-facing answers is now a first-class blocker;
- the practical product risk is no longer "the route collapsed", but "a new user can still feel that the assistant is glitchy, misses intent, or answers the wrong thing on short live wording".
- the main remaining risk is no longer clarification-resume collapse, but the unfinished shift from bounded reviewed chains toward open-world data-need-driven MCP planning;
- pure wording polish is now secondary debt, but semantic robustness plus open-world evidence navigation is now a first-class blocker;
- the practical product risk is no longer only "the route collapsed", but "the assistant still cannot yet understand and explore many non-preworked 1C questions on its own".
- main remaining architectural pressure:
- no single fully authoritative continuity contract consumed by every hot runtime owner
- residual coordinator/legacy pressure inside `assistantService.ts`
- no general `Question -> Data Need Graph` authority yet
- planner chain selection is still reviewed-family bounded rather than open-world over the primitive catalog
- schema traversal is still narrower than the intended arbitrary 1C blast radius
- multi-hop evidence recovery is still too shallow for unfamiliar asks
- central domain-intent pressure inside `resolveAddressIntent()`
- replay breadth still narrower than the intended multi-domain rollout surface beyond the flagship and late-switch families
- remaining answer-semantics pressure inside `composeStage.ts` / `answerComposer.ts`
- insufficient semantic robustness on live user wording, especially short follow-up retarget, typo tolerance, and intent-faithful human answers
- no guarded MCP semantic discovery lane yet for understood long-tail 1C questions that should not require one-off route hardcoding
- replay breadth is still below the future open-world autonomy surface
Latest live proof now includes:
@ -72,14 +80,17 @@ Latest live proof now includes:
- `address_truth_harness_phase15_answer_inspection_followup_live_20260419_rerun11` accepted `9/9`
- `address_truth_harness_phase16_multicompany_late_pivot_live_20260419_rerun10` accepted
- `address_truth_harness_phase17_clarification_resume_and_counterparty_tail_live_20260419_rerun5` accepted `10/10`
- `address_truth_harness_phase24_metadata_lane_choice_loop_live_rerun5` accepted
- `address_truth_harness_phase25_entity_resolution_chain_live_rerun_full_chain` accepted
- `address_truth_harness_phase32_planner_selected_chain_end_to_end_live_rerun2` accepted `6/6`
Current architectural reading:
- the system is already materially past the dangerous regression breakpoint;
- it is now safe for continued architecture hardening and controlled domain-by-domain enablement under replay gates;
- it is now materially closer to pre-multidomain stability, but still not safe to declare broad low-risk multi-domain expansion.
- the practical next target is now `90%+ pre-multidomain readiness`, and the remaining gap should be treated as five large architecture iterations rather than as cosmetic cleanup.
- from this point onward, readiness must be judged not only by route truth and replay pass rate, but also by whether a new human user would feel that the assistant understands the intent and responds meaningfully in live wording.
- the practical next target is no longer only `90%+ pre-multidomain readiness`, but the first believable `open-world bounded autonomy` over 1C evidence.
- from this point onward, readiness must be judged not only by route truth and replay pass rate, but also by whether a new human user can ask a structurally new 1C data question and still get a bounded, evidence-honest answer path.
For the detailed audit, current percentages, and remaining debt, read:
@ -90,6 +101,8 @@ For the detailed audit, current percentages, and remaining debt, read:
- [12 - manual_run_system_analysis_3NilqwT1G2_2026-04-18.md](./12%20-%20manual_run_system_analysis_3NilqwT1G2_2026-04-18.md)
- [13 - pre_multidomain_readiness_audit_2026-04-18.md](./13%20-%20pre_multidomain_readiness_audit_2026-04-18.md)
- [14 - semantic_dialog_authority_recovery_plan_2026-04-19.md](./14%20-%20semantic_dialog_authority_recovery_plan_2026-04-19.md)
- [15 - mcp_bounded_autonomy_reset_plan_2026-04-21.md](./15%20-%20mcp_bounded_autonomy_reset_plan_2026-04-21.md)
- [16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md](./16%20-%20data_need_graph_and_open_world_mcp_plan_2026-04-22.md)
## Architectural Objects Of Planning
@ -122,6 +135,8 @@ Read in this order:
13. `12 - manual_run_system_analysis_3NilqwT1G2_2026-04-18.md`
14. `13 - pre_multidomain_readiness_audit_2026-04-18.md`
15. `14 - semantic_dialog_authority_recovery_plan_2026-04-19.md`
16. `15 - mcp_bounded_autonomy_reset_plan_2026-04-21.md`
17. `16 - data_need_graph_and_open_world_mcp_plan_2026-04-22.md`
## Planning Rules
@ -141,15 +156,14 @@ and start being described as:
- "a stateful exact-data assistant with explicit transition contracts and isolated truth gating."
As of `2026-04-19`, the project is already materially closer to the target description and is no longer in the same acute collapse state. The remaining blocker is no longer the original continuity failure itself, but the unfinished convergence toward one runtime authority plus still-insufficient replay breadth for low-risk multi-domain expansion.
As of `2026-04-22`, the project is already materially closer to the target description and is no longer in the same acute collapse state. The remaining blocker is no longer the original continuity failure itself, but the unfinished convergence from reviewed bounded MCP chains toward open-world data-need-driven autonomy with replay breadth still below the future blast radius.
The biggest remaining blockers are:
- split continuity ownership across route / transition / recap / coordinator glue;
- saved-session acceptance still too narrow compared with the intended domain-expansion blast radius outside the repaired flagship + late-pivot families;
- clarification precedence is much better than before, but still not yet proven widely enough outside the repaired replay family;
- no general `Question -> Data Need Graph` runtime authority yet;
- planner-selected primitive chains are real, but still narrower than open-world primitive search;
- dynamic schema traversal is not yet broad enough for unfamiliar 1C asks outside the repaired families;
- multi-hop evidence recovery still depends on bounded reviewed seams and not yet on a general exploration loop;
- residual `assistantService` overload;
- central intent pressure in `resolveAddressIntent()`;
- remaining answer-semantics pressure in `composeStage.ts` and `answerComposer.ts`.
- semantic robustness gaps where already-supported questions can still look broken to a human user because of typo sensitivity, short follow-up retarget loss, or human-answer mismatch.
- missing MCP semantic data-discovery layer where Qwen3 can help plan controlled 1C evidence search without bypassing runtime truth gates.

View File

@ -0,0 +1,35 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "address_truth_harness_phase33_open_scope_value_flow_comparison",
"domain": "address_phase33_open_scope_value_flow_comparison",
"title": "Phase 33 open-scope value-flow comparison replay",
"description": "Targeted AGENT replay for Big Block D where an open-scope incoming-vs-outgoing money question must be understood as a bounded comparison need, not as a missing-counterparty fact ask.",
"bindings": {},
"steps": [
{
"step_id": "step_01_compare_incoming_vs_outgoing_for_org",
"title": "Raw organization-scoped comparison wording produces a bounded incoming-vs-outgoing answer without inventing a counterparty",
"question": "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
"allowed_reply_types": ["factual_with_explanation", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)2020",
"(?i)входящ|получили|поступ",
"(?i)исходящ|заплатили|списан|платеж",
"(?i)руб"
],
"required_answer_patterns_any": [
"(?i)нетто|сальдо",
"(?i)больше|превыш",
"(?i)альтернатива"
],
"forbidden_answer_patterns": [
"(?i)уточните контрагента",
"(?i)не найден контрагент",
"(?i)по какому контрагенту",
"(?i)не найдено контрагента"
],
"criticality": "critical",
"semantic_tags": ["value_flow_comparison", "open_scope", "organization_scoped", "bounded_autonomy"]
}
]
}

View File

@ -141,6 +141,16 @@ function isMovementLaneClarification(pilot) {
askedActionFamily(pilot) === "list_movements" ||
unsupportedFamily(pilot) === "movement_evidence");
}
function isRankedValueFlowClarification(pilot) {
return (pilot.reason_codes.includes("planner_selected_top_ranked_value_flow_from_data_need_graph") ||
pilot.reason_codes.includes("planner_selected_bottom_ranked_value_flow_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_top_ranked_value_flow_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_bottom_ranked_value_flow_from_data_need_graph"));
}
function isBidirectionalValueFlowComparisonClarification(pilot) {
return (pilot.reason_codes.includes("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph"));
}
function isDocumentLaneClarification(pilot) {
return (isDocumentPilot(pilot) ||
pilot.reason_codes.includes("planner_selected_document_recipe") ||
@ -152,12 +162,20 @@ function laneScopeSuffix(pilot) {
const entity = firstEntityCandidate(pilot);
return entity ? ` по "${entity}"` : "";
}
function dryRunHasAxis(pilot, axis) {
return pilot.dry_run.execution_steps.some((step) => step.provided_axes.includes(axis));
}
function dryRunMissingAxis(pilot, axis) {
if (dryRunHasAxis(pilot, axis)) {
return false;
}
return pilot.dry_run.execution_steps.some((step) => step.missing_axis_options.some((option) => option.includes(axis)));
}
function clarificationNeedRu(pilot) {
const hasCounterparty = dryRunHasAxis(pilot, "counterparty");
const hasAccount = dryRunHasAxis(pilot, "account");
const needsPeriod = dryRunMissingAxis(pilot, "period");
const needsOrganization = dryRunMissingAxis(pilot, "organization");
const needsOrganization = !hasCounterparty && !hasAccount && dryRunMissingAxis(pilot, "organization");
if (needsPeriod && needsOrganization) {
return { subject: "проверяемый период и организацию", verb: "нужно" };
}
@ -210,6 +228,9 @@ function headlineFor(mode, pilot) {
pilot.derived_entity_resolution?.resolution_status === "not_found") {
return "По текущему каталожному поиску 1С точный контрагент пока не подтвержден.";
}
if (pilot.derived_ranked_value_flow && mode === "confirmed_with_bounded_inference") {
return "По данным 1С можно построить ограниченный ranking по контрагентам на подтвержденных строках денежных движений.";
}
if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") {
return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`;
}
@ -269,6 +290,14 @@ function headlineFor(mode, pilot) {
const need = clarificationNeedRu(pilot);
return `Могу идти дальше по документам${laneScopeSuffix(pilot)}, но для запуска поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification" && isBidirectionalValueFlowComparisonClarification(pilot)) {
const need = clarificationNeedRu(pilot);
return `Могу сравнить входящий и исходящий денежный поток, но для bounded поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) {
const need = clarificationNeedRu(pilot);
return `Могу посчитать ranking по денежному потоку между контрагентами, но для bounded поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification") {
return "Нужно уточнить контекст перед поиском в 1С.";
}
@ -302,6 +331,12 @@ function nextStepFor(mode, pilot) {
if (mode === "needs_clarification" && isDocumentLaneClarification(pilot)) {
return clarificationNextStepLine(pilot, "документам");
}
if (mode === "needs_clarification" && isBidirectionalValueFlowComparisonClarification(pilot)) {
return clarificationNextStepLine(pilot, "сравнению входящих и исходящих денежных потоков");
}
if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) {
return clarificationNextStepLine(pilot, "ranking-поиску между контрагентами");
}
if (mode === "needs_clarification") {
return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С.";
}
@ -336,6 +371,10 @@ function buildMustNotClaim(pilot) {
claims.push("Do not claim full all-time turnover unless the checked period and coverage prove it.");
claims.push("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows.");
}
if (pilot.derived_ranked_value_flow) {
claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization.");
claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist.");
}
if (isDocumentPilot(pilot)) {
claims.push("Do not claim full document history outside the checked period.");
claims.push("Do not present the confirmed document rows as a complete document universe.");
@ -463,6 +502,40 @@ function derivedEntityResolutionInferenceLine(pilot) {
}
return null;
}
function derivedRankedValueFlowInferenceLine(pilot) {
const ranking = pilot.derived_ranked_value_flow;
if (!ranking) {
return null;
}
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
return `Ranking по контрагентам${organization}${period} рассчитан только по подтвержденным строкам 1С и не доказывает полный исторический срез вне проверенного окна.`;
}
function derivedRankedValueFlowConfirmedLine(pilot) {
const ranking = pilot.derived_ranked_value_flow;
if (!ranking || ranking.ranked_values.length <= 0) {
return null;
}
const leader = ranking.ranked_values[0];
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
const directionLead = ranking.ranking_need === "bottom_asc"
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "Меньше всего заплатили контрагенту"
: "Меньше всего денег принёс контрагент"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "Больше всего заплатили контрагенту"
: "Больше всего денег принёс контрагент";
const tail = ranking.ranked_values
.slice(1, 3)
.map((bucket) => `${bucket.axis_value}${bucket.total_amount_human_ru}`)
.join("; ");
const trail = tail ? ` Следом: ${tail}.` : "";
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; ranking может быть неполным."
: "";
return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`;
}
function derivedValueFlowConfirmedLine(pilot) {
const flow = pilot.derived_value_flow;
if (!flow) {
@ -553,13 +626,16 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
}
const derivedInferenceLine = derivedActivityInferenceLine(pilot) ??
derivedMetadataInferenceLine(pilot) ??
derivedRankedValueFlowInferenceLine(pilot) ??
derivedEntityResolutionInferenceLine(pilot);
const inferenceLines = derivedInferenceLine
? [derivedInferenceLine]
: pilot.evidence.inferred_facts;
const derivedMetadataLine = derivedMetadataConfirmedLine(pilot);
const derivedEntityResolutionLine = derivedEntityResolutionConfirmedLine(pilot);
const derivedValueLine = derivedBidirectionalValueFlowConfirmedLine(pilot) ?? derivedValueFlowConfirmedLine(pilot);
const derivedValueLine = derivedBidirectionalValueFlowConfirmedLine(pilot) ??
derivedRankedValueFlowConfirmedLine(pilot) ??
derivedValueFlowConfirmedLine(pilot);
const monthlyConfirmedLines = derivedBidirectionalValueFlowMonthlyLines(pilot).length > 0
? derivedBidirectionalValueFlowMonthlyLines(pilot)
: derivedValueFlowMonthlyLines(pilot);

View File

@ -0,0 +1,286 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryDataNeedGraph = buildAssistantMcpDiscoveryDataNeedGraph;
exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION = "assistant_data_need_graph_v1";
function toNonEmptyString(value) {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function lower(value) {
return String(value ?? "").trim().toLowerCase();
}
function normalizeReasonCode(value) {
const normalized = value
.trim()
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized.length > 0 ? normalized.slice(0, 120) : null;
}
function pushReason(target, value) {
const normalized = normalizeReasonCode(value);
if (normalized && !target.includes(normalized)) {
target.push(normalized);
}
}
function pushUnique(target, value) {
const text = toNonEmptyString(value);
if (text && !target.includes(text)) {
target.push(text);
}
}
function businessFactFamilyFor(input) {
const combined = `${input.semanticDataNeed} ${input.domain} ${input.action} ${input.unsupported}`.trim();
if (combined.includes("metadata lane clarification")) {
return "schema_surface";
}
if (combined.includes("metadata")) {
return "schema_surface";
}
if (combined.includes("entity discovery") || combined.includes("entity_resolution")) {
return "entity_grounding";
}
if (combined.includes("lifecycle") || combined.includes("activity")) {
return "activity_lifecycle";
}
if (combined.includes("movement")) {
return "movement_evidence";
}
if (combined.includes("document")) {
return "document_evidence";
}
if (combined.includes("value-flow") || combined.includes("turnover") || combined.includes("payout") || combined.includes("net")) {
return "value_flow";
}
return null;
}
function aggregationNeedFor(axis) {
if (!axis) {
return null;
}
if (axis === "month") {
return "by_month";
}
return `by_${axis}`;
}
function timeScopeNeedFor(input) {
if (input.explicitDateScope) {
return "explicit_period";
}
if (input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence") {
return "period_required";
}
if (input.family === "activity_lifecycle") {
return "open_activity_window";
}
return null;
}
function comparisonNeedFor(action) {
if (action === "net_value_flow") {
return "incoming_vs_outgoing";
}
return null;
}
function allowsOpenScopeWithoutSubject(input) {
if (input.family !== "value_flow") {
return false;
}
return Boolean(input.rankingNeed || input.comparisonNeed === "incoming_vs_outgoing");
}
function rankingNeedFromRawUtterance(value) {
const text = lower(value);
if (!text) {
return null;
}
if (/(?:\btop[-\s]?\d+\b|\btop\b|топ[-\s]?\d+|топ\b|сам(?:ый|ая|ое|ые)\b|больше\s+всего|наибол[её]е|highest|largest|most)/iu.test(text)) {
return "top_desc";
}
if (/(?:меньше\s+всего|наимен[ьш]е|lowest|smallest|least)/iu.test(text)) {
return "bottom_asc";
}
return null;
}
function proofExpectationFor(input) {
if (input.clarificationGaps.length > 0) {
return "clarification_required";
}
if (input.family === "schema_surface") {
return "schema_surface";
}
if (input.family === "entity_grounding") {
return "entity_grounding";
}
if (input.family === "activity_lifecycle") {
return "bounded_inference";
}
return "coverage_checked_fact";
}
function decompositionCandidatesFor(input) {
const result = [];
if (input.family === "schema_surface") {
pushUnique(result, "inspect_metadata_surface");
return result;
}
if (input.family === "entity_grounding") {
pushUnique(result, "search_business_entity");
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "value_flow") {
if (input.rankingNeed && input.openScopeWithoutSubject) {
pushUnique(result, "collect_scoped_movements");
pushUnique(result, "aggregate_ranked_axis_values");
pushUnique(result, "probe_coverage");
return result;
}
if (input.comparisonNeed === "incoming_vs_outgoing" && input.openScopeWithoutSubject) {
pushUnique(result, "collect_incoming_movements");
pushUnique(result, "collect_outgoing_movements");
if (input.aggregationNeed === "by_month") {
pushUnique(result, "aggregate_by_month");
}
pushUnique(result, "probe_coverage");
return result;
}
pushUnique(result, "resolve_entity_reference");
if (input.action === "net_value_flow") {
pushUnique(result, "collect_incoming_movements");
pushUnique(result, "collect_outgoing_movements");
}
else {
pushUnique(result, "collect_scoped_movements");
}
pushUnique(result, input.aggregationNeed === "by_month" ? "aggregate_by_month" : "aggregate_checked_amounts");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "movement_evidence") {
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "fetch_scoped_movements");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "document_evidence") {
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "fetch_scoped_documents");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "activity_lifecycle") {
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "fetch_supporting_documents");
pushUnique(result, "probe_coverage");
pushUnique(result, "explain_evidence_basis");
}
return result;
}
function forbiddenOverclaimFlagsFor(family) {
const result = ["no_raw_model_claims"];
if (family === "schema_surface") {
pushUnique(result, "no_fake_schema_surface");
}
if (family === "entity_grounding") {
pushUnique(result, "no_unresolved_entity_claim");
}
if (family === "activity_lifecycle") {
pushUnique(result, "no_legal_age_claim_without_evidence");
}
if (family === "value_flow" || family === "movement_evidence" || family === "document_evidence") {
pushUnique(result, "no_unchecked_fact_totals");
}
return result;
}
function buildAssistantMcpDiscoveryDataNeedGraph(input) {
const semanticDataNeed = lower(input.semanticDataNeed);
const turnMeaning = input.turnMeaning ?? null;
const domain = lower(turnMeaning?.asked_domain_family);
const action = lower(turnMeaning?.asked_action_family);
const unsupported = lower(turnMeaning?.unsupported_but_understood_family);
const rawUtterance = lower(input.rawUtterance);
const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis);
const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope);
const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? [])
.map((item) => toNonEmptyString(item))
.filter((item) => Boolean(item));
const businessFactFamily = businessFactFamilyFor({
semanticDataNeed,
domain,
action,
unsupported
});
const aggregationNeed = aggregationNeedFor(aggregationAxis);
const comparisonNeed = comparisonNeedFor(action);
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance);
const openScopeWithoutSubject = subjectCandidates.length === 0 &&
allowsOpenScopeWithoutSubject({
family: businessFactFamily,
comparisonNeed,
rankingNeed
});
const clarificationGaps = [];
if (unsupported === "metadata_lane_choice_clarification" || action === "resolve_next_lane") {
pushUnique(clarificationGaps, "lane_family_choice");
}
if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) {
pushUnique(clarificationGaps, "subject");
}
const timeScopeNeed = timeScopeNeedFor({
family: businessFactFamily,
explicitDateScope
});
if (timeScopeNeed === "period_required" && !explicitDateScope) {
pushUnique(clarificationGaps, "period");
}
const decompositionCandidates = decompositionCandidatesFor({
family: businessFactFamily,
action,
aggregationNeed,
comparisonNeed,
rankingNeed,
openScopeWithoutSubject
});
const reasonCodes = [];
pushReason(reasonCodes, "data_need_graph_built");
if (businessFactFamily) {
pushReason(reasonCodes, `data_need_graph_family_${businessFactFamily}`);
}
else {
pushReason(reasonCodes, "data_need_graph_family_unknown");
}
if (aggregationNeed) {
pushReason(reasonCodes, `data_need_graph_aggregation_${aggregationNeed}`);
}
if (rankingNeed) {
pushReason(reasonCodes, `data_need_graph_ranking_${rankingNeed}`);
}
if (comparisonNeed) {
pushReason(reasonCodes, `data_need_graph_comparison_${comparisonNeed}`);
}
if (clarificationGaps.length > 0) {
pushReason(reasonCodes, "data_need_graph_has_clarification_gaps");
}
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: subjectCandidates,
business_fact_family: businessFactFamily,
action_family: toNonEmptyString(turnMeaning?.asked_action_family),
aggregation_need: aggregationNeed,
time_scope_need: timeScopeNeed,
comparison_need: comparisonNeed,
ranking_need: rankingNeed,
proof_expectation: proofExpectationFor({
family: businessFactFamily,
clarificationGaps
}),
clarification_gaps: clarificationGaps,
decomposition_candidates: decompositionCandidates,
forbidden_overclaim_flags: forbiddenOverclaimFlagsFor(businessFactFamily),
reason_codes: reasonCodes
};
}

View File

@ -133,6 +133,16 @@ function buildValueFlowFilters(planner) {
sort: "period_asc"
};
}
function organizationScopeForPlanner(planner) {
return toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope);
}
function rankingNeedForPlanner(planner) {
const rankingNeed = toNonEmptyString(planner.data_need_graph?.ranking_need)?.toLowerCase();
if (rankingNeed === "top_desc" || rankingNeed === "bottom_asc") {
return rankingNeed;
}
return null;
}
function normalizeEntityResolutionText(value) {
return String(value ?? "")
.toLowerCase()
@ -313,7 +323,9 @@ function isMovementEvidencePilotEligible(planner) {
combined.includes("list_movements")));
}
function isValueFlowPilotEligible(planner) {
if (planner.selected_chain_id === "value_flow") {
if (planner.selected_chain_id === "value_flow" ||
planner.selected_chain_id === "value_flow_ranking" ||
planner.selected_chain_id === "value_flow_comparison") {
return true;
}
const meaning = planner.discovery_plan.turn_meaning_ref;
@ -1040,6 +1052,16 @@ function rowAmountValue(row) {
}
return null;
}
function rowCounterpartyValue(row) {
const candidates = [row["Контрагент"], row["Counterparty"], row["counterparty"], row["Наименование"], row["name"]];
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
if (text) {
return text;
}
}
return null;
}
function monthBucketFromIsoDate(isoDate) {
const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/);
return match ? `${match[1]}-${match[2]}` : null;
@ -1213,6 +1235,62 @@ function deriveValueFlow(result, counterparty, periodScope, direction, aggregati
inference_basis: "sum_of_confirmed_1c_value_flow_rows"
};
}
function deriveRankedValueFlow(result, input) {
if (!result || result.error || result.matched_rows <= 0) {
return null;
}
const buckets = new Map();
let rowsWithAmount = 0;
for (const row of result.rows) {
const axisValue = rowCounterpartyValue(row);
const amount = rowAmountValue(row);
if (!axisValue || amount === null) {
continue;
}
rowsWithAmount += 1;
const current = buckets.get(axisValue) ?? { rows_with_amount: 0, total_amount: 0 };
current.rows_with_amount += 1;
current.total_amount += amount;
buckets.set(axisValue, current);
}
if (rowsWithAmount <= 0 || buckets.size <= 0) {
return null;
}
const rankedValues = Array.from(buckets.entries())
.map(([axisValue, bucket]) => ({
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount)
}))
.sort((left, right) => {
const amountDelta = right.total_amount - left.total_amount;
if (input.rankingNeed === "bottom_asc") {
if (amountDelta !== 0) {
return -amountDelta;
}
}
else if (amountDelta !== 0) {
return amountDelta;
}
return left.axis_value.localeCompare(right.axis_value, "ru");
})
.slice(0, 5);
return {
value_flow_direction: input.direction,
ranking_need: input.rankingNeed,
ranking_axis: "counterparty",
organization_scope: input.organizationScope,
period_scope: input.periodScope,
rows_matched: result.matched_rows,
rows_with_amount: rowsWithAmount,
ranked_values: rankedValues,
coverage_limited_by_probe_limit: result.coverage_limited_by_probe_limit,
coverage_recovered_by_period_chunking: result.coverage_recovered_by_period_chunking,
period_chunking_granularity: result.period_chunking_granularity,
inference_basis: "ranked_counterparty_totals_from_confirmed_1c_value_flow_rows"
};
}
function deriveValueFlowSideSummary(result) {
if (!result || result.error || result.matched_rows <= 0) {
return {
@ -1345,6 +1423,16 @@ function buildValueFlowConfirmedFacts(result, counterparty, direction) {
: "1C value-flow rows were found for the requested counterparty scope"
];
}
function buildRankedValueFlowConfirmedFacts(derived) {
if (!derived || derived.ranked_values.length <= 0) {
return [];
}
const leader = derived.ranked_values[0];
const directionLabel = derived.value_flow_direction === "outgoing_supplier_payout" ? "supplier-payout" : "incoming value-flow";
return [
`1C ${directionLabel} rows were ranked by counterparty for the checked scope; leader=${leader.axis_value}, rows_with_amount=${leader.rows_with_amount}`
];
}
function buildBidirectionalValueFlowConfirmedFacts(derived) {
if (!derived) {
return [];
@ -1411,6 +1499,16 @@ function buildValueFlowInferredFacts(derived) {
}
return facts;
}
function buildRankedValueFlowInferredFacts(derived) {
if (!derived) {
return [];
}
const facts = ["Counterparty ranking was calculated from confirmed 1C movement rows grouped by counterparty"];
if (derived.coverage_recovered_by_period_chunking && derived.period_chunking_granularity === "month") {
facts.push("Requested period coverage for counterparty ranking was recovered through monthly 1C probes after a broad probe hit the row limit");
}
return facts;
}
function buildBidirectionalValueFlowInferredFacts(derived) {
if (!derived) {
return [];
@ -1453,6 +1551,16 @@ function buildValueFlowUnknownFacts(periodScope, direction, derived) {
: "Full all-time turnover is not proven without an explicit checked period");
return unknownFacts;
}
function buildRankedValueFlowUnknownFacts(periodScope, derived) {
const unknownFacts = [];
if (derived?.coverage_limited_by_probe_limit) {
unknownFacts.push("Complete requested-period ranking coverage is not proven because the MCP discovery probe row limit was reached");
}
unknownFacts.push(periodScope
? "Full ranking outside the checked period is not proven by this MCP discovery pilot"
: "Full all-time counterparty ranking is not proven without an explicit checked period");
return unknownFacts;
}
function buildBidirectionalValueFlowUnknownFacts(periodScope, derived) {
const unknownFacts = [];
if (derived?.coverage_limited_by_probe_limit) {
@ -1479,6 +1587,8 @@ function pilotScopeForPlanner(planner) {
return "metadata_inspection_v1";
case "movement_evidence":
return "counterparty_movement_evidence_query_movements_v1";
case "value_flow_comparison":
case "value_flow_ranking":
case "value_flow":
return valueFlowPilotProfile(planner).scope;
case "document_evidence":
@ -1595,7 +1705,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
}
const counterparty = firstEntityCandidate(planner);
const dateScope = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_date_scope);
const organizationScope = organizationScopeForPlanner(planner);
const aggregationAxis = aggregationAxisForPlanner(planner);
const rankingNeed = rankingNeedForPlanner(planner);
if (metadataPilotEligible) {
let metadataResult = null;
const metadataScope = metadataScopeForPlanner(planner);
@ -2151,6 +2263,48 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
}
}
const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null;
if (planner.selected_chain_id === "value_flow_ranking" && rankingNeed) {
const derivedRankedValueFlow = deriveRankedValueFlow(queryResult, {
organizationScope,
periodScope: dateScope,
direction: valueFlowProfile.direction,
rankingNeed
});
if (derivedRankedValueFlow) {
pushReason(reasonCodes, "pilot_derived_ranked_value_flow_from_confirmed_rows");
}
const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({
plan: planner.discovery_plan,
probeResults,
confirmedFacts: buildRankedValueFlowConfirmedFacts(derivedRankedValueFlow),
inferredFacts: buildRankedValueFlowInferredFacts(derivedRankedValueFlow),
unknownFacts: buildRankedValueFlowUnknownFacts(dateScope, derivedRankedValueFlow),
sourceRowsSummary,
queryLimitations,
recommendedNextProbe: "explain_evidence_basis"
});
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryPilotExecutor",
pilot_status: "executed",
pilot_scope: valueFlowProfile.scope,
dry_run: dryRun,
mcp_execution_performed: executedPrimitives.length > 0,
executed_primitives: executedPrimitives,
skipped_primitives: skippedPrimitives,
probe_results: probeResults,
evidence,
source_rows_summary: sourceRowsSummary,
derived_metadata_surface: null,
derived_entity_resolution: null,
derived_activity_period: null,
derived_ranked_value_flow: derivedRankedValueFlow,
derived_value_flow: null,
derived_bidirectional_value_flow: null,
query_limitations: queryLimitations,
reason_codes: reasonCodes
};
}
const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope, valueFlowProfile.direction, aggregationAxis);
if (derivedValueFlow) {
pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows");
@ -2183,6 +2337,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
derived_metadata_surface: null,
derived_entity_resolution: null,
derived_activity_period: null,
derived_ranked_value_flow: null,
derived_value_flow: derivedValueFlow,
derived_bidirectional_value_flow: null,
query_limitations: queryLimitations,

View File

@ -38,6 +38,9 @@ function pushUnique(target, value) {
function hasEntity(meaning) {
return (meaning?.explicit_entity_candidates?.length ?? 0) > 0;
}
function hasSubjectCandidates(graph) {
return (graph?.subject_candidates.length ?? 0) > 0;
}
function aggregationAxis(meaning) {
return toNonEmptyString(meaning?.asked_aggregation_axis)?.toLowerCase() ?? null;
}
@ -75,13 +78,137 @@ function budgetOverrideFor(input, recipe) {
}
function recipeFor(input) {
const meaning = input.turnMeaning ?? null;
const dataNeedGraph = input.dataNeedGraph ?? null;
const domain = lower(meaning?.asked_domain_family);
const action = lower(meaning?.asked_action_family);
const unsupported = lower(meaning?.unsupported_but_understood_family);
const graphFactFamily = lower(dataNeedGraph?.business_fact_family);
const graphAction = lower(dataNeedGraph?.action_family);
const graphAggregation = lower(dataNeedGraph?.aggregation_need);
const graphClarificationGaps = (dataNeedGraph?.clarification_gaps ?? []).map((item) => lower(item));
const combined = `${domain} ${action} ${unsupported}`.trim();
const axes = [];
const requestedAggregationAxis = aggregationAxis(meaning);
addScopeAxes(axes, meaning);
if (graphClarificationGaps.includes("lane_family_choice")) {
pushUnique(axes, "lane_family_choice");
return {
semanticDataNeed: "metadata lane clarification",
chainId: "metadata_lane_clarification",
chainSummary: "Preserve the ambiguous metadata surface and ask the user to choose the next data lane before running MCP probes.",
primitives: [],
axes,
reason: "planner_selected_metadata_lane_clarification_from_data_need_graph"
};
}
if (graphFactFamily === "value_flow") {
if (dataNeedGraph?.comparison_need === "incoming_vs_outgoing" && !hasSubjectCandidates(dataNeedGraph)) {
pushUnique(axes, "amount");
pushUnique(axes, "coverage_target");
if (requestedAggregationAxis === "month" || graphAggregation === "by_month") {
pushUnique(axes, "calendar_month");
}
return {
semanticDataNeed: "bidirectional value-flow comparison evidence",
chainId: "value_flow_comparison",
chainSummary: "Query incoming and outgoing movements for the checked period and organization, compare the checked sides, and probe coverage before answering a bounded comparison.",
primitives: ["query_movements", "probe_coverage"],
axes,
reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph"
};
}
if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) {
pushUnique(axes, "aggregate_axis");
pushUnique(axes, "amount");
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "ranked value-flow evidence",
chainId: "value_flow_ranking",
chainSummary: "Query scoped movements for the checked period and organization, aggregate checked amounts by counterparty, then probe coverage before answering a bounded ranking.",
primitives: ["query_movements", "aggregate_by_axis", "probe_coverage"],
axes,
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"
};
}
pushUnique(axes, "aggregate_axis");
pushUnique(axes, "amount");
pushUnique(axes, "coverage_target");
if (requestedAggregationAxis === "month" || graphAggregation === "by_month") {
pushUnique(axes, "calendar_month");
}
return {
semanticDataNeed: "counterparty value-flow evidence",
chainId: "value_flow",
chainSummary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.",
primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"],
axes,
reason: requestedAggregationAxis === "month" || graphAggregation === "by_month"
? "planner_selected_monthly_value_flow_from_data_need_graph"
: "planner_selected_value_flow_from_data_need_graph"
};
}
if (graphFactFamily === "activity_lifecycle") {
pushUnique(axes, "document_date");
pushUnique(axes, "coverage_target");
pushUnique(axes, "evidence_basis");
return {
semanticDataNeed: "counterparty lifecycle evidence",
chainId: "lifecycle",
chainSummary: "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.",
primitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"],
axes,
reason: "planner_selected_lifecycle_from_data_need_graph"
};
}
if (graphFactFamily === "schema_surface") {
pushUnique(axes, "metadata_scope");
return {
semanticDataNeed: "1C metadata evidence",
chainId: "metadata_inspection",
chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.",
primitives: ["inspect_1c_metadata"],
axes,
reason: "planner_selected_metadata_from_data_need_graph"
};
}
if (graphFactFamily === "movement_evidence") {
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "movement evidence",
chainId: "movement_evidence",
chainSummary: "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.",
primitives: ["resolve_entity_reference", "query_movements", "probe_coverage"],
axes,
reason: "planner_selected_movement_from_data_need_graph"
};
}
if (graphFactFamily === "document_evidence") {
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "document evidence",
chainId: "document_evidence",
chainSummary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.",
primitives: ["resolve_entity_reference", "query_documents", "probe_coverage"],
axes,
reason: "planner_selected_document_from_data_need_graph"
};
}
if (graphFactFamily === "entity_grounding" || (!graphFactFamily && (dataNeedGraph?.subject_candidates.length ?? 0) > 0)) {
pushUnique(axes, "business_entity");
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "entity discovery evidence",
chainId: "entity_resolution",
chainSummary: "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.",
primitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"],
axes,
reason: graphAction === "search_business_entity"
? "planner_selected_entity_resolution_from_data_need_graph"
: "planner_selected_entity_resolution_recipe"
};
}
if (includesAny(combined, ["metadata_lane_choice_clarification", "resolve_next_lane"])) {
pushUnique(axes, "lane_family_choice");
return {
@ -191,8 +318,12 @@ function planAssistantMcpDiscovery(input) {
const recipe = recipeFor(input);
const budgetOverride = budgetOverrideFor(input, recipe);
const semanticDataNeed = toNonEmptyString(input.semanticDataNeed) ?? recipe.semanticDataNeed;
const dataNeedGraph = input.dataNeedGraph ?? null;
const reasonCodes = [];
pushReason(reasonCodes, recipe.reason);
if (dataNeedGraph) {
pushReason(reasonCodes, "planner_consumed_data_need_graph_v1");
}
if (budgetOverride.maxProbeCount) {
pushReason(reasonCodes, "planner_enabled_chunked_coverage_probe_budget");
}
@ -219,6 +350,7 @@ function planAssistantMcpDiscovery(input) {
policy_owner: "assistantMcpDiscoveryPlanner",
planner_status: plannerStatus,
semantic_data_need: semanticDataNeed,
data_need_graph: dataNeedGraph,
selected_chain_id: recipe.chainId,
selected_chain_summary: recipe.chainSummary,
proposed_primitives: recipe.primitives,

View File

@ -61,6 +61,28 @@ function userFacingLines(values) {
return uniqueStrings(values).filter((line) => !hasInternalMechanics(line));
}
function localizeLine(value) {
if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки активности в запрошенном срезе.";
}
if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки входящих денежных поступлений в запрошенном срезе.";
}
if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки исходящих платежей и списаний в запрошенном срезе.";
}
const openScopeBidirectionalMatch = value.match(/^1C bidirectional value-flow rows were checked for the requested counterparty scope: incoming=(found|not_found), outgoing=(found|not_found)$/i);
if (openScopeBidirectionalMatch) {
const incoming = openScopeBidirectionalMatch[1] === "found"
? "входящие строки найдены"
: "входящие строки не найдены";
const outgoing = openScopeBidirectionalMatch[2] === "found"
? "исходящие строки найдены"
: "исходящие строки не найдены";
return `В 1С проверены входящие и исходящие денежные строки в запрошенном срезе: ${incoming}, ${outgoing}.`;
}
if (/^Requested period hit the MCP row limit, but the approved monthly recovery probe budget is smaller than the required subperiod count$/i.test(value)) {
return "Запрошенный период уперся в лимит строк MCP; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды.";
}
const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i);
if (counterpartyMatch) {
return `В 1С найдены строки активности по контрагенту ${counterpartyMatch[1]}.`;

View File

@ -51,6 +51,7 @@ function businessFactAnswerAllowed(draft) {
async function runAssistantMcpDiscoveryRuntimeBridge(input) {
const planner = (0, assistantMcpDiscoveryPlanner_1.planAssistantMcpDiscovery)({
semanticDataNeed: input.semanticDataNeed,
dataNeedGraph: input.dataNeedGraph,
turnMeaning: input.turnMeaning
});
const pilot = await (0, assistantMcpDiscoveryPilotExecutor_1.executeAssistantMcpDiscoveryPilot)(planner, input.deps);

View File

@ -62,6 +62,7 @@ async function runAssistantMcpDiscoveryRuntimeEntryPoint(input) {
}
const bridge = await (0, assistantMcpDiscoveryRuntimeBridge_1.runAssistantMcpDiscoveryRuntimeBridge)({
semanticDataNeed: turnInput.semantic_data_need,
dataNeedGraph: turnInput.data_need_graph,
turnMeaning: turnInput.turn_meaning_ref,
deps: input.deps
});

View File

@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryTurnInput = buildAssistantMcpDiscoveryTurnInput;
const assistantMcpDiscoveryDataNeedGraph_1 = require("./assistantMcpDiscoveryDataNeedGraph");
exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION = "assistant_mcp_discovery_turn_input_v1";
function toRecordObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@ -71,6 +72,9 @@ function compactLower(value) {
.replace(/\s+/g, " ")
.trim();
}
function sameScopedName(left, right) {
return Boolean(left && right && compactLower(left) === compactLower(right));
}
function candidateValue(value) {
const direct = toNonEmptyString(value);
if (direct && direct !== "[object Object]") {
@ -298,6 +302,9 @@ function hasPayoutSignal(text) {
function hasBidirectionalValueFlowSignal(text) {
return /(?:нетто|сальдо|баланс\s+(?:плат|денег|денеж)|взаиморасч[её]т|получил[иа]?.*(?:за)?платил|(?:за)?платил[иа]?.*получил|входящ.*исходящ|исходящ.*входящ|дебет.*кредит|кредит.*дебет|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(text);
}
function hasValueRankingSignal(text) {
return /(?:кто\s+больше\s+всего.*ден[её]г|больше\s+всего.*ден[её]г|прин[её]с.*ден[её]г|сам(?:ый|ая|ое|ые).*(?:доходн|прибыльн)|most.*money|highest\s+(?:revenue|payment))/iu.test(text);
}
function hasMonthlyAggregationSignal(text) {
return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test(text);
}
@ -551,7 +558,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawText = compactLower(rawSignalSourceText);
const rawLifecycleSignal = hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowSignal = !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowSignal = !rawLifecycleSignal && (hasValueFlowSignal(rawText) || rawBidirectionalValueFlowSignal);
const rawValueFlowSignal = !rawLifecycleSignal &&
(hasValueFlowSignal(rawText) || hasValueRankingSignal(rawText) || rawBidirectionalValueFlowSignal);
const rawMetadataSignal = !rawLifecycleSignal && !rawValueFlowSignal && hasMetadataSignal(rawText);
const rawEntityResolutionSignal = !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
@ -574,6 +582,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization);
const organizationMirrorsPredecomposeCounterparty = Boolean((rawBidirectionalValueFlowSignal || hasValueRankingSignal(rawText)) &&
(sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
predecomposeOrganizationMirrorsCounterparty));
const normalizedPredecomposeCounterparty = organizationMirrorsPredecomposeCounterparty
? null
: predecomposeEntities.counterparty;
const predecomposeDateScope = collectDateScope(predecomposeContract);
const followupDiscoverySeedApplicable = Boolean(followupSeed.domain &&
!rawLifecycleSignal &&
@ -791,7 +806,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, candidate);
}
pushNormalizedEntityResolutionCandidate(entityCandidates, predecomposeEntities.counterparty);
pushNormalizedEntityResolutionCandidate(entityCandidates, normalizedPredecomposeCounterparty);
pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty);
}
else {
@ -801,7 +816,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity);
}
pushScopedEntityCandidate(entityCandidates, predecomposeEntities.counterparty, groundedFollowupEntity);
pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity);
if (!groundedFollowupEntity) {
pushScopedEntityCandidate(entityCandidates, followupSeed.counterparty, null);
pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null);
@ -812,13 +827,23 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
pushUnique(entityCandidates, followupSeed.discoveryEntity);
pushUnique(entityCandidates, rawMetadataScopeHint);
}
if (valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty) {
const openScopeValueFlowWithoutCounterparty = valueFlowSignal && !normalizedPredecomposeCounterparty && !followupSeed.counterparty;
const valueFlowOrganizationStaysScope = openScopeValueFlowWithoutCounterparty &&
(bidirectionalValueFlowSignal || hasValueRankingSignal(rawText));
if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) {
pushUnique(entityCandidates, predecomposeEntities.organization);
pushUnique(entityCandidates, followupSeed.organization);
}
const explicitOrganizationScope = valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty
? null
: predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization;
const explicitOrganizationScope = valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty
? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization
: null;
if (valueFlowOrganizationStaysScope && explicitOrganizationScope) {
for (let index = entityCandidates.length - 1; index >= 0; index -= 1) {
if (entityCandidates[index] === explicitOrganizationScope) {
entityCandidates.splice(index, 1);
}
}
}
const explicitDateScope = assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope;
const turnMeaning = {
asked_domain_family: lifecycleSignal
@ -1054,7 +1079,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (unsupported) {
pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn");
}
if (predecomposeEntities.counterparty) {
if (!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
normalizedPredecomposeCounterparty) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose");
}
if (followupSeed.counterparty) {
@ -1072,12 +1098,23 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (runDiscovery && !hasTurnMeaning) {
pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing");
}
const dataNeedGraph = runDiscovery && hasTurnMeaning
? (0, assistantMcpDiscoveryDataNeedGraph_1.buildAssistantMcpDiscoveryDataNeedGraph)({
semanticDataNeed,
rawUtterance: rawSignalSourceText,
turnMeaning: cleanTurnMeaning
})
: null;
if (dataNeedGraph) {
pushReason(reasonCodes, "mcp_discovery_data_need_graph_built");
}
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryTurnInputAdapter",
adapter_status: !runDiscovery ? "not_applicable" : hasTurnMeaning ? "ready" : "needs_more_context",
should_run_discovery: runDiscovery,
semantic_data_need: runDiscovery ? semanticDataNeed : null,
data_need_graph: dataNeedGraph,
turn_meaning_ref: runDiscovery && hasTurnMeaning ? cleanTurnMeaning : null,
source_signal: sourceSignal,
reason_codes: reasonCodes

View File

@ -192,6 +192,24 @@ function isMovementLaneClarification(pilot: AssistantMcpDiscoveryPilotExecutionC
);
}
function isRankedValueFlowClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
return (
pilot.reason_codes.includes("planner_selected_top_ranked_value_flow_from_data_need_graph") ||
pilot.reason_codes.includes("planner_selected_bottom_ranked_value_flow_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_top_ranked_value_flow_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_bottom_ranked_value_flow_from_data_need_graph")
);
}
function isBidirectionalValueFlowComparisonClarification(
pilot: AssistantMcpDiscoveryPilotExecutionContract
): boolean {
return (
pilot.reason_codes.includes("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph")
);
}
function isDocumentLaneClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
return (
isDocumentPilot(pilot) ||
@ -207,7 +225,14 @@ function laneScopeSuffix(pilot: AssistantMcpDiscoveryPilotExecutionContract): st
return entity ? ` по "${entity}"` : "";
}
function dryRunHasAxis(pilot: AssistantMcpDiscoveryPilotExecutionContract, axis: string): boolean {
return pilot.dry_run.execution_steps.some((step) => step.provided_axes.includes(axis));
}
function dryRunMissingAxis(pilot: AssistantMcpDiscoveryPilotExecutionContract, axis: string): boolean {
if (dryRunHasAxis(pilot, axis)) {
return false;
}
return pilot.dry_run.execution_steps.some((step) =>
step.missing_axis_options.some((option) => option.includes(axis))
);
@ -216,8 +241,10 @@ function dryRunMissingAxis(pilot: AssistantMcpDiscoveryPilotExecutionContract, a
function clarificationNeedRu(
pilot: AssistantMcpDiscoveryPilotExecutionContract
): { subject: string; verb: string } {
const hasCounterparty = dryRunHasAxis(pilot, "counterparty");
const hasAccount = dryRunHasAxis(pilot, "account");
const needsPeriod = dryRunMissingAxis(pilot, "period");
const needsOrganization = dryRunMissingAxis(pilot, "organization");
const needsOrganization = !hasCounterparty && !hasAccount && dryRunMissingAxis(pilot, "organization");
if (needsPeriod && needsOrganization) {
return { subject: "проверяемый период и организацию", verb: "нужно" };
}
@ -281,6 +308,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
) {
return "По текущему каталожному поиску 1С точный контрагент пока не подтвержден.";
}
if (pilot.derived_ranked_value_flow && mode === "confirmed_with_bounded_inference") {
return "По данным 1С можно построить ограниченный ranking по контрагентам на подтвержденных строках денежных движений.";
}
if (isMovementPilot(pilot) && mode === "confirmed_with_bounded_inference") {
return `По движениям${documentOrMovementScopeRu(pilot)} в 1С найдены подтвержденные строки; ответ ограничен проверенным окном и найденными строками.`;
}
@ -340,6 +370,14 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
const need = clarificationNeedRu(pilot);
return `Могу идти дальше по документам${laneScopeSuffix(pilot)}, но для запуска поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification" && isBidirectionalValueFlowComparisonClarification(pilot)) {
const need = clarificationNeedRu(pilot);
return `Могу сравнить входящий и исходящий денежный поток, но для bounded поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) {
const need = clarificationNeedRu(pilot);
return `Могу посчитать ranking по денежному потоку между контрагентами, но для bounded поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification") {
return "Нужно уточнить контекст перед поиском в 1С.";
}
@ -378,6 +416,12 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
if (mode === "needs_clarification" && isDocumentLaneClarification(pilot)) {
return clarificationNextStepLine(pilot, "документам");
}
if (mode === "needs_clarification" && isBidirectionalValueFlowComparisonClarification(pilot)) {
return clarificationNextStepLine(pilot, "сравнению входящих и исходящих денежных потоков");
}
if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) {
return clarificationNextStepLine(pilot, "ranking-поиску между контрагентами");
}
if (mode === "needs_clarification") {
return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С.";
}
@ -413,6 +457,10 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract):
claims.push("Do not claim full all-time turnover unless the checked period and coverage prove it.");
claims.push("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows.");
}
if (pilot.derived_ranked_value_flow) {
claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization.");
claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist.");
}
if (isDocumentPilot(pilot)) {
claims.push("Do not claim full document history outside the checked period.");
claims.push("Do not present the confirmed document rows as a complete document universe.");
@ -556,6 +604,43 @@ function derivedEntityResolutionInferenceLine(pilot: AssistantMcpDiscoveryPilotE
return null;
}
function derivedRankedValueFlowInferenceLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
const ranking = pilot.derived_ranked_value_flow;
if (!ranking) {
return null;
}
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
return `Ranking по контрагентам${organization}${period} рассчитан только по подтвержденным строкам 1С и не доказывает полный исторический срез вне проверенного окна.`;
}
function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
const ranking = pilot.derived_ranked_value_flow;
if (!ranking || ranking.ranked_values.length <= 0) {
return null;
}
const leader = ranking.ranked_values[0];
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
const directionLead =
ranking.ranking_need === "bottom_asc"
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "Меньше всего заплатили контрагенту"
: "Меньше всего денег принёс контрагент"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "Больше всего заплатили контрагенту"
: "Больше всего денег принёс контрагент";
const tail = ranking.ranked_values
.slice(1, 3)
.map((bucket) => `${bucket.axis_value}${bucket.total_amount_human_ru}`)
.join("; ");
const trail = tail ? ` Следом: ${tail}.` : "";
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; ranking может быть неполным."
: "";
return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`;
}
function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
const flow = pilot.derived_value_flow;
if (!flow) {
@ -662,13 +747,17 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
const derivedInferenceLine =
derivedActivityInferenceLine(pilot) ??
derivedMetadataInferenceLine(pilot) ??
derivedRankedValueFlowInferenceLine(pilot) ??
derivedEntityResolutionInferenceLine(pilot);
const inferenceLines = derivedInferenceLine
? [derivedInferenceLine]
: pilot.evidence.inferred_facts;
const derivedMetadataLine = derivedMetadataConfirmedLine(pilot);
const derivedEntityResolutionLine = derivedEntityResolutionConfirmedLine(pilot);
const derivedValueLine = derivedBidirectionalValueFlowConfirmedLine(pilot) ?? derivedValueFlowConfirmedLine(pilot);
const derivedValueLine =
derivedBidirectionalValueFlowConfirmedLine(pilot) ??
derivedRankedValueFlowConfirmedLine(pilot) ??
derivedValueFlowConfirmedLine(pilot);
const monthlyConfirmedLines =
derivedBidirectionalValueFlowMonthlyLines(pilot).length > 0
? derivedBidirectionalValueFlowMonthlyLines(pilot)

View File

@ -0,0 +1,358 @@
import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy";
export const ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION =
"assistant_data_need_graph_v1" as const;
export type AssistantMcpDiscoveryDataNeedProofExpectation =
| "schema_surface"
| "entity_grounding"
| "coverage_checked_fact"
| "bounded_inference"
| "clarification_required";
export interface AssistantMcpDiscoveryDataNeedGraphContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryDataNeedGraph";
subject_candidates: string[];
business_fact_family: string | null;
action_family: string | null;
aggregation_need: string | null;
time_scope_need: string | null;
comparison_need: string | null;
ranking_need: string | null;
proof_expectation: AssistantMcpDiscoveryDataNeedProofExpectation;
clarification_gaps: string[];
decomposition_candidates: string[];
forbidden_overclaim_flags: string[];
reason_codes: string[];
}
export interface BuildAssistantMcpDiscoveryDataNeedGraphInput {
semanticDataNeed?: string | null;
rawUtterance?: string | null;
turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null;
}
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function lower(value: unknown): string {
return String(value ?? "").trim().toLowerCase();
}
function normalizeReasonCode(value: string): string | null {
const normalized = value
.trim()
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase();
return normalized.length > 0 ? normalized.slice(0, 120) : null;
}
function pushReason(target: string[], value: string): void {
const normalized = normalizeReasonCode(value);
if (normalized && !target.includes(normalized)) {
target.push(normalized);
}
}
function pushUnique(target: string[], value: string | null | undefined): void {
const text = toNonEmptyString(value);
if (text && !target.includes(text)) {
target.push(text);
}
}
function businessFactFamilyFor(input: {
semanticDataNeed: string;
domain: string;
action: string;
unsupported: string;
}): string | null {
const combined = `${input.semanticDataNeed} ${input.domain} ${input.action} ${input.unsupported}`.trim();
if (combined.includes("metadata lane clarification")) {
return "schema_surface";
}
if (combined.includes("metadata")) {
return "schema_surface";
}
if (combined.includes("entity discovery") || combined.includes("entity_resolution")) {
return "entity_grounding";
}
if (combined.includes("lifecycle") || combined.includes("activity")) {
return "activity_lifecycle";
}
if (combined.includes("movement")) {
return "movement_evidence";
}
if (combined.includes("document")) {
return "document_evidence";
}
if (combined.includes("value-flow") || combined.includes("turnover") || combined.includes("payout") || combined.includes("net")) {
return "value_flow";
}
return null;
}
function aggregationNeedFor(axis: string): string | null {
if (!axis) {
return null;
}
if (axis === "month") {
return "by_month";
}
return `by_${axis}`;
}
function timeScopeNeedFor(input: {
family: string | null;
explicitDateScope: string | null;
}): string | null {
if (input.explicitDateScope) {
return "explicit_period";
}
if (input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence") {
return "period_required";
}
if (input.family === "activity_lifecycle") {
return "open_activity_window";
}
return null;
}
function comparisonNeedFor(action: string): string | null {
if (action === "net_value_flow") {
return "incoming_vs_outgoing";
}
return null;
}
function allowsOpenScopeWithoutSubject(input: {
family: string | null;
comparisonNeed: string | null;
rankingNeed: string | null;
}): boolean {
if (input.family !== "value_flow") {
return false;
}
return Boolean(input.rankingNeed || input.comparisonNeed === "incoming_vs_outgoing");
}
function rankingNeedFromRawUtterance(value: string): string | null {
const text = lower(value);
if (!text) {
return null;
}
if (
/(?:\btop[-\s]?\d+\b|\btop\b|топ[-\s]?\d+|топ\b|сам(?:ый|ая|ое|ые)\b|больше\s+всего|наибол[её]е|highest|largest|most)/iu.test(
text
)
) {
return "top_desc";
}
if (/(?:меньше\s+всего|наимен[ьш]е|lowest|smallest|least)/iu.test(text)) {
return "bottom_asc";
}
return null;
}
function proofExpectationFor(input: {
family: string | null;
clarificationGaps: string[];
}): AssistantMcpDiscoveryDataNeedProofExpectation {
if (input.clarificationGaps.length > 0) {
return "clarification_required";
}
if (input.family === "schema_surface") {
return "schema_surface";
}
if (input.family === "entity_grounding") {
return "entity_grounding";
}
if (input.family === "activity_lifecycle") {
return "bounded_inference";
}
return "coverage_checked_fact";
}
function decompositionCandidatesFor(input: {
family: string | null;
action: string;
aggregationNeed: string | null;
comparisonNeed: string | null;
rankingNeed: string | null;
openScopeWithoutSubject: boolean;
}): string[] {
const result: string[] = [];
if (input.family === "schema_surface") {
pushUnique(result, "inspect_metadata_surface");
return result;
}
if (input.family === "entity_grounding") {
pushUnique(result, "search_business_entity");
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "value_flow") {
if (input.rankingNeed && input.openScopeWithoutSubject) {
pushUnique(result, "collect_scoped_movements");
pushUnique(result, "aggregate_ranked_axis_values");
pushUnique(result, "probe_coverage");
return result;
}
if (input.comparisonNeed === "incoming_vs_outgoing" && input.openScopeWithoutSubject) {
pushUnique(result, "collect_incoming_movements");
pushUnique(result, "collect_outgoing_movements");
if (input.aggregationNeed === "by_month") {
pushUnique(result, "aggregate_by_month");
}
pushUnique(result, "probe_coverage");
return result;
}
pushUnique(result, "resolve_entity_reference");
if (input.action === "net_value_flow") {
pushUnique(result, "collect_incoming_movements");
pushUnique(result, "collect_outgoing_movements");
} else {
pushUnique(result, "collect_scoped_movements");
}
pushUnique(result, input.aggregationNeed === "by_month" ? "aggregate_by_month" : "aggregate_checked_amounts");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "movement_evidence") {
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "fetch_scoped_movements");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "document_evidence") {
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "fetch_scoped_documents");
pushUnique(result, "probe_coverage");
return result;
}
if (input.family === "activity_lifecycle") {
pushUnique(result, "resolve_entity_reference");
pushUnique(result, "fetch_supporting_documents");
pushUnique(result, "probe_coverage");
pushUnique(result, "explain_evidence_basis");
}
return result;
}
function forbiddenOverclaimFlagsFor(family: string | null): string[] {
const result: string[] = ["no_raw_model_claims"];
if (family === "schema_surface") {
pushUnique(result, "no_fake_schema_surface");
}
if (family === "entity_grounding") {
pushUnique(result, "no_unresolved_entity_claim");
}
if (family === "activity_lifecycle") {
pushUnique(result, "no_legal_age_claim_without_evidence");
}
if (family === "value_flow" || family === "movement_evidence" || family === "document_evidence") {
pushUnique(result, "no_unchecked_fact_totals");
}
return result;
}
export function buildAssistantMcpDiscoveryDataNeedGraph(
input: BuildAssistantMcpDiscoveryDataNeedGraphInput
): AssistantMcpDiscoveryDataNeedGraphContract {
const semanticDataNeed = lower(input.semanticDataNeed);
const turnMeaning = input.turnMeaning ?? null;
const domain = lower(turnMeaning?.asked_domain_family);
const action = lower(turnMeaning?.asked_action_family);
const unsupported = lower(turnMeaning?.unsupported_but_understood_family);
const rawUtterance = lower(input.rawUtterance);
const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis);
const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope);
const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? [])
.map((item) => toNonEmptyString(item))
.filter((item): item is string => Boolean(item));
const businessFactFamily = businessFactFamilyFor({
semanticDataNeed,
domain,
action,
unsupported
});
const aggregationNeed = aggregationNeedFor(aggregationAxis);
const comparisonNeed = comparisonNeedFor(action);
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance);
const openScopeWithoutSubject =
subjectCandidates.length === 0 &&
allowsOpenScopeWithoutSubject({
family: businessFactFamily,
comparisonNeed,
rankingNeed
});
const clarificationGaps: string[] = [];
if (unsupported === "metadata_lane_choice_clarification" || action === "resolve_next_lane") {
pushUnique(clarificationGaps, "lane_family_choice");
}
if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) {
pushUnique(clarificationGaps, "subject");
}
const timeScopeNeed = timeScopeNeedFor({
family: businessFactFamily,
explicitDateScope
});
if (timeScopeNeed === "period_required" && !explicitDateScope) {
pushUnique(clarificationGaps, "period");
}
const decompositionCandidates = decompositionCandidatesFor({
family: businessFactFamily,
action,
aggregationNeed,
comparisonNeed,
rankingNeed,
openScopeWithoutSubject
});
const reasonCodes: string[] = [];
pushReason(reasonCodes, "data_need_graph_built");
if (businessFactFamily) {
pushReason(reasonCodes, `data_need_graph_family_${businessFactFamily}`);
} else {
pushReason(reasonCodes, "data_need_graph_family_unknown");
}
if (aggregationNeed) {
pushReason(reasonCodes, `data_need_graph_aggregation_${aggregationNeed}`);
}
if (rankingNeed) {
pushReason(reasonCodes, `data_need_graph_ranking_${rankingNeed}`);
}
if (comparisonNeed) {
pushReason(reasonCodes, `data_need_graph_comparison_${comparisonNeed}`);
}
if (clarificationGaps.length > 0) {
pushReason(reasonCodes, "data_need_graph_has_clarification_gaps");
}
return {
schema_version: ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: subjectCandidates,
business_fact_family: businessFactFamily,
action_family: toNonEmptyString(turnMeaning?.asked_action_family),
aggregation_need: aggregationNeed,
time_scope_need: timeScopeNeed,
comparison_need: comparisonNeed,
ranking_need: rankingNeed,
proof_expectation: proofExpectationFor({
family: businessFactFamily,
clarificationGaps
}),
clarification_gaps: clarificationGaps,
decomposition_candidates: decompositionCandidates,
forbidden_overclaim_flags: forbiddenOverclaimFlagsFor(businessFactFamily),
reason_codes: reasonCodes
};
}

View File

@ -78,6 +78,28 @@ export interface AssistantMcpDiscoveryDerivedValueFlow {
inference_basis: "sum_of_confirmed_1c_value_flow_rows";
}
export interface AssistantMcpDiscoveryRankedValueFlowBucket {
axis_value: string;
rows_with_amount: number;
total_amount: number;
total_amount_human_ru: string;
}
export interface AssistantMcpDiscoveryDerivedRankedValueFlow {
value_flow_direction: "incoming_customer_revenue" | "outgoing_supplier_payout";
ranking_need: "top_desc" | "bottom_asc";
ranking_axis: "counterparty";
organization_scope: string | null;
period_scope: string | null;
rows_matched: number;
rows_with_amount: number;
ranked_values: AssistantMcpDiscoveryRankedValueFlowBucket[];
coverage_limited_by_probe_limit: boolean;
coverage_recovered_by_period_chunking: boolean;
period_chunking_granularity: AssistantMcpDiscoveryAggregationAxis | null;
inference_basis: "ranked_counterparty_totals_from_confirmed_1c_value_flow_rows";
}
export interface AssistantMcpDiscoveryValueFlowSideSummary {
rows_matched: number;
rows_with_amount: number;
@ -187,6 +209,7 @@ export interface AssistantMcpDiscoveryPilotExecutionContract {
derived_metadata_surface: AssistantMcpDiscoveryDerivedMetadataSurface | null;
derived_entity_resolution: AssistantMcpDiscoveryDerivedEntityResolution | null;
derived_activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null;
derived_ranked_value_flow?: AssistantMcpDiscoveryDerivedRankedValueFlow | null;
derived_value_flow: AssistantMcpDiscoveryDerivedValueFlow | null;
derived_bidirectional_value_flow: AssistantMcpDiscoveryDerivedBidirectionalValueFlow | null;
query_limitations: string[];
@ -334,6 +357,20 @@ function buildValueFlowFilters(planner: AssistantMcpDiscoveryPlannerContract): A
};
}
function organizationScopeForPlanner(planner: AssistantMcpDiscoveryPlannerContract): string | null {
return toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope);
}
function rankingNeedForPlanner(
planner: AssistantMcpDiscoveryPlannerContract
): AssistantMcpDiscoveryDerivedRankedValueFlow["ranking_need"] | null {
const rankingNeed = toNonEmptyString(planner.data_need_graph?.ranking_need)?.toLowerCase();
if (rankingNeed === "top_desc" || rankingNeed === "bottom_asc") {
return rankingNeed;
}
return null;
}
function normalizeEntityResolutionText(value: string | null): string {
return String(value ?? "")
.toLowerCase()
@ -544,7 +581,11 @@ function isMovementEvidencePilotEligible(planner: AssistantMcpDiscoveryPlannerCo
}
function isValueFlowPilotEligible(planner: AssistantMcpDiscoveryPlannerContract): boolean {
if (planner.selected_chain_id === "value_flow") {
if (
planner.selected_chain_id === "value_flow" ||
planner.selected_chain_id === "value_flow_ranking" ||
planner.selected_chain_id === "value_flow_comparison"
) {
return true;
}
const meaning = planner.discovery_plan.turn_meaning_ref;
@ -1429,6 +1470,17 @@ function rowAmountValue(row: Record<string, unknown>): number | null {
return null;
}
function rowCounterpartyValue(row: Record<string, unknown>): string | null {
const candidates = [row["Контрагент"], row["Counterparty"], row["counterparty"], row["Наименование"], row["name"]];
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
if (text) {
return text;
}
}
return null;
}
function monthBucketFromIsoDate(isoDate: string | null): string | null {
const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/);
return match ? `${match[1]}-${match[2]}` : null;
@ -1629,6 +1681,74 @@ function deriveValueFlow(
};
}
function deriveRankedValueFlow(
result: AssistantMcpDiscoveryCoverageAwareQueryResult | null,
input: {
organizationScope: string | null;
periodScope: string | null;
direction: AssistantMcpDiscoveryDerivedRankedValueFlow["value_flow_direction"];
rankingNeed: AssistantMcpDiscoveryDerivedRankedValueFlow["ranking_need"];
}
): AssistantMcpDiscoveryDerivedRankedValueFlow | null {
if (!result || result.error || result.matched_rows <= 0) {
return null;
}
const buckets = new Map<string, { rows_with_amount: number; total_amount: number }>();
let rowsWithAmount = 0;
for (const row of result.rows) {
const axisValue = rowCounterpartyValue(row);
const amount = rowAmountValue(row);
if (!axisValue || amount === null) {
continue;
}
rowsWithAmount += 1;
const current = buckets.get(axisValue) ?? { rows_with_amount: 0, total_amount: 0 };
current.rows_with_amount += 1;
current.total_amount += amount;
buckets.set(axisValue, current);
}
if (rowsWithAmount <= 0 || buckets.size <= 0) {
return null;
}
const rankedValues = Array.from(buckets.entries())
.map(([axisValue, bucket]) => ({
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount)
}))
.sort((left, right) => {
const amountDelta = right.total_amount - left.total_amount;
if (input.rankingNeed === "bottom_asc") {
if (amountDelta !== 0) {
return -amountDelta;
}
} else if (amountDelta !== 0) {
return amountDelta;
}
return left.axis_value.localeCompare(right.axis_value, "ru");
})
.slice(0, 5);
return {
value_flow_direction: input.direction,
ranking_need: input.rankingNeed,
ranking_axis: "counterparty",
organization_scope: input.organizationScope,
period_scope: input.periodScope,
rows_matched: result.matched_rows,
rows_with_amount: rowsWithAmount,
ranked_values: rankedValues,
coverage_limited_by_probe_limit: result.coverage_limited_by_probe_limit,
coverage_recovered_by_period_chunking: result.coverage_recovered_by_period_chunking,
period_chunking_granularity: result.period_chunking_granularity,
inference_basis: "ranked_counterparty_totals_from_confirmed_1c_value_flow_rows"
};
}
function deriveValueFlowSideSummary(
result: AssistantMcpDiscoveryCoverageAwareQueryResult | null
): AssistantMcpDiscoveryValueFlowSideSummary {
@ -1798,6 +1918,18 @@ function buildValueFlowConfirmedFacts(
];
}
function buildRankedValueFlowConfirmedFacts(derived: AssistantMcpDiscoveryDerivedRankedValueFlow | null): string[] {
if (!derived || derived.ranked_values.length <= 0) {
return [];
}
const leader = derived.ranked_values[0];
const directionLabel =
derived.value_flow_direction === "outgoing_supplier_payout" ? "supplier-payout" : "incoming value-flow";
return [
`1C ${directionLabel} rows were ranked by counterparty for the checked scope; leader=${leader.axis_value}, rows_with_amount=${leader.rows_with_amount}`
];
}
function buildBidirectionalValueFlowConfirmedFacts(
derived: AssistantMcpDiscoveryDerivedBidirectionalValueFlow | null
): string[] {
@ -1880,6 +2012,19 @@ function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueF
return facts;
}
function buildRankedValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedRankedValueFlow | null): string[] {
if (!derived) {
return [];
}
const facts = ["Counterparty ranking was calculated from confirmed 1C movement rows grouped by counterparty"];
if (derived.coverage_recovered_by_period_chunking && derived.period_chunking_granularity === "month") {
facts.push(
"Requested period coverage for counterparty ranking was recovered through monthly 1C probes after a broad probe hit the row limit"
);
}
return facts;
}
function buildBidirectionalValueFlowInferredFacts(
derived: AssistantMcpDiscoveryDerivedBidirectionalValueFlow | null
): string[] {
@ -1939,6 +2084,22 @@ function buildValueFlowUnknownFacts(
return unknownFacts;
}
function buildRankedValueFlowUnknownFacts(
periodScope: string | null,
derived: AssistantMcpDiscoveryDerivedRankedValueFlow | null
): string[] {
const unknownFacts: string[] = [];
if (derived?.coverage_limited_by_probe_limit) {
unknownFacts.push("Complete requested-period ranking coverage is not proven because the MCP discovery probe row limit was reached");
}
unknownFacts.push(
periodScope
? "Full ranking outside the checked period is not proven by this MCP discovery pilot"
: "Full all-time counterparty ranking is not proven without an explicit checked period"
);
return unknownFacts;
}
function buildBidirectionalValueFlowUnknownFacts(
periodScope: string | null,
derived: AssistantMcpDiscoveryDerivedBidirectionalValueFlow | null
@ -1979,6 +2140,8 @@ function pilotScopeForPlanner(planner: AssistantMcpDiscoveryPlannerContract): As
return "metadata_inspection_v1";
case "movement_evidence":
return "counterparty_movement_evidence_query_movements_v1";
case "value_flow_comparison":
case "value_flow_ranking":
case "value_flow":
return valueFlowPilotProfile(planner).scope;
case "document_evidence":
@ -2107,7 +2270,9 @@ export async function executeAssistantMcpDiscoveryPilot(
const counterparty = firstEntityCandidate(planner);
const dateScope = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_date_scope);
const organizationScope = organizationScopeForPlanner(planner);
const aggregationAxis = aggregationAxisForPlanner(planner);
const rankingNeed = rankingNeedForPlanner(planner);
if (metadataPilotEligible) {
let metadataResult: AddressMcpMetadataRowsResult | null = null;
@ -2694,6 +2859,50 @@ export async function executeAssistantMcpDiscoveryPilot(
}
const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null;
if (planner.selected_chain_id === "value_flow_ranking" && rankingNeed) {
const derivedRankedValueFlow = deriveRankedValueFlow(queryResult, {
organizationScope,
periodScope: dateScope,
direction: valueFlowProfile.direction,
rankingNeed
});
if (derivedRankedValueFlow) {
pushReason(reasonCodes, "pilot_derived_ranked_value_flow_from_confirmed_rows");
}
const evidence = resolveAssistantMcpDiscoveryEvidence({
plan: planner.discovery_plan,
probeResults,
confirmedFacts: buildRankedValueFlowConfirmedFacts(derivedRankedValueFlow),
inferredFacts: buildRankedValueFlowInferredFacts(derivedRankedValueFlow),
unknownFacts: buildRankedValueFlowUnknownFacts(dateScope, derivedRankedValueFlow),
sourceRowsSummary,
queryLimitations,
recommendedNextProbe: "explain_evidence_basis"
});
return {
schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryPilotExecutor",
pilot_status: "executed",
pilot_scope: valueFlowProfile.scope,
dry_run: dryRun,
mcp_execution_performed: executedPrimitives.length > 0,
executed_primitives: executedPrimitives,
skipped_primitives: skippedPrimitives,
probe_results: probeResults,
evidence,
source_rows_summary: sourceRowsSummary,
derived_metadata_surface: null,
derived_entity_resolution: null,
derived_activity_period: null,
derived_ranked_value_flow: derivedRankedValueFlow,
derived_value_flow: null,
derived_bidirectional_value_flow: null,
query_limitations: queryLimitations,
reason_codes: reasonCodes
};
}
const derivedValueFlow = deriveValueFlow(
queryResult,
counterparty,
@ -2733,6 +2942,7 @@ export async function executeAssistantMcpDiscoveryPilot(
derived_metadata_surface: null,
derived_entity_resolution: null,
derived_activity_period: null,
derived_ranked_value_flow: null,
derived_value_flow: derivedValueFlow,
derived_bidirectional_value_flow: null,
query_limitations: queryLimitations,

View File

@ -8,6 +8,7 @@ import {
reviewAssistantMcpDiscoveryPlanAgainstCatalog,
type AssistantMcpCatalogPlanReview
} from "./assistantMcpCatalogIndex";
import type { AssistantMcpDiscoveryDataNeedGraphContract } from "./assistantMcpDiscoveryDataNeedGraph";
export const ASSISTANT_MCP_DISCOVERY_PLANNER_SCHEMA_VERSION = "assistant_mcp_discovery_planner_v1" as const;
@ -17,6 +18,8 @@ export type AssistantMcpDiscoveryChainId =
| "metadata_inspection"
| "metadata_lane_clarification"
| "value_flow"
| "value_flow_comparison"
| "value_flow_ranking"
| "lifecycle"
| "movement_evidence"
| "document_evidence"
@ -24,6 +27,7 @@ export type AssistantMcpDiscoveryChainId =
export interface AssistantMcpDiscoveryPlannerInput {
semanticDataNeed?: string | null;
dataNeedGraph?: AssistantMcpDiscoveryDataNeedGraphContract | null;
turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null;
}
@ -32,6 +36,7 @@ export interface AssistantMcpDiscoveryPlannerContract {
policy_owner: "assistantMcpDiscoveryPlanner";
planner_status: AssistantMcpDiscoveryPlannerStatus;
semantic_data_need: string | null;
data_need_graph: AssistantMcpDiscoveryDataNeedGraphContract | null;
selected_chain_id: AssistantMcpDiscoveryChainId;
selected_chain_summary: string;
proposed_primitives: AssistantMcpDiscoveryPrimitive[];
@ -93,6 +98,10 @@ function hasEntity(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefin
return (meaning?.explicit_entity_candidates?.length ?? 0) > 0;
}
function hasSubjectCandidates(graph: AssistantMcpDiscoveryDataNeedGraphContract | null | undefined): boolean {
return (graph?.subject_candidates.length ?? 0) > 0;
}
function aggregationAxis(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): string | null {
return toNonEmptyString(meaning?.asked_aggregation_axis)?.toLowerCase() ?? null;
}
@ -136,14 +145,150 @@ function budgetOverrideFor(input: AssistantMcpDiscoveryPlannerInput, recipe: Pla
function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe {
const meaning = input.turnMeaning ?? null;
const dataNeedGraph = input.dataNeedGraph ?? null;
const domain = lower(meaning?.asked_domain_family);
const action = lower(meaning?.asked_action_family);
const unsupported = lower(meaning?.unsupported_but_understood_family);
const graphFactFamily = lower(dataNeedGraph?.business_fact_family);
const graphAction = lower(dataNeedGraph?.action_family);
const graphAggregation = lower(dataNeedGraph?.aggregation_need);
const graphClarificationGaps = (dataNeedGraph?.clarification_gaps ?? []).map((item) => lower(item));
const combined = `${domain} ${action} ${unsupported}`.trim();
const axes: string[] = [];
const requestedAggregationAxis = aggregationAxis(meaning);
addScopeAxes(axes, meaning);
if (graphClarificationGaps.includes("lane_family_choice")) {
pushUnique(axes, "lane_family_choice");
return {
semanticDataNeed: "metadata lane clarification",
chainId: "metadata_lane_clarification",
chainSummary: "Preserve the ambiguous metadata surface and ask the user to choose the next data lane before running MCP probes.",
primitives: [],
axes,
reason: "planner_selected_metadata_lane_clarification_from_data_need_graph"
};
}
if (graphFactFamily === "value_flow") {
if (dataNeedGraph?.comparison_need === "incoming_vs_outgoing" && !hasSubjectCandidates(dataNeedGraph)) {
pushUnique(axes, "amount");
pushUnique(axes, "coverage_target");
if (requestedAggregationAxis === "month" || graphAggregation === "by_month") {
pushUnique(axes, "calendar_month");
}
return {
semanticDataNeed: "bidirectional value-flow comparison evidence",
chainId: "value_flow_comparison",
chainSummary:
"Query incoming and outgoing movements for the checked period and organization, compare the checked sides, and probe coverage before answering a bounded comparison.",
primitives: ["query_movements", "probe_coverage"],
axes,
reason: "planner_selected_bidirectional_value_flow_comparison_from_data_need_graph"
};
}
if (dataNeedGraph?.ranking_need && !hasSubjectCandidates(dataNeedGraph)) {
pushUnique(axes, "aggregate_axis");
pushUnique(axes, "amount");
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "ranked value-flow evidence",
chainId: "value_flow_ranking",
chainSummary:
"Query scoped movements for the checked period and organization, aggregate checked amounts by counterparty, then probe coverage before answering a bounded ranking.",
primitives: ["query_movements", "aggregate_by_axis", "probe_coverage"],
axes,
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"
};
}
pushUnique(axes, "aggregate_axis");
pushUnique(axes, "amount");
pushUnique(axes, "coverage_target");
if (requestedAggregationAxis === "month" || graphAggregation === "by_month") {
pushUnique(axes, "calendar_month");
}
return {
semanticDataNeed: "counterparty value-flow evidence",
chainId: "value_flow",
chainSummary: "Resolve the business entity, query scoped movements, aggregate checked amounts, then probe coverage before answering.",
primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"],
axes,
reason:
requestedAggregationAxis === "month" || graphAggregation === "by_month"
? "planner_selected_monthly_value_flow_from_data_need_graph"
: "planner_selected_value_flow_from_data_need_graph"
};
}
if (graphFactFamily === "activity_lifecycle") {
pushUnique(axes, "document_date");
pushUnique(axes, "coverage_target");
pushUnique(axes, "evidence_basis");
return {
semanticDataNeed: "counterparty lifecycle evidence",
chainId: "lifecycle",
chainSummary: "Resolve the business entity, query supporting documents, probe coverage, then explain the evidence basis for the inferred activity window.",
primitives: ["resolve_entity_reference", "query_documents", "probe_coverage", "explain_evidence_basis"],
axes,
reason: "planner_selected_lifecycle_from_data_need_graph"
};
}
if (graphFactFamily === "schema_surface") {
pushUnique(axes, "metadata_scope");
return {
semanticDataNeed: "1C metadata evidence",
chainId: "metadata_inspection",
chainSummary: "Inspect the 1C metadata surface first, then ground the next safe lane from confirmed schema evidence.",
primitives: ["inspect_1c_metadata"],
axes,
reason: "planner_selected_metadata_from_data_need_graph"
};
}
if (graphFactFamily === "movement_evidence") {
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "movement evidence",
chainId: "movement_evidence",
chainSummary: "Resolve the business entity, fetch scoped movement rows, and probe coverage without pretending to have a full movement universe.",
primitives: ["resolve_entity_reference", "query_movements", "probe_coverage"],
axes,
reason: "planner_selected_movement_from_data_need_graph"
};
}
if (graphFactFamily === "document_evidence") {
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "document evidence",
chainId: "document_evidence",
chainSummary: "Resolve the business entity, fetch scoped document rows, and probe coverage before stating the checked document evidence.",
primitives: ["resolve_entity_reference", "query_documents", "probe_coverage"],
axes,
reason: "planner_selected_document_from_data_need_graph"
};
}
if (graphFactFamily === "entity_grounding" || (!graphFactFamily && (dataNeedGraph?.subject_candidates.length ?? 0) > 0)) {
pushUnique(axes, "business_entity");
pushUnique(axes, "coverage_target");
return {
semanticDataNeed: "entity discovery evidence",
chainId: "entity_resolution",
chainSummary: "Search candidate business entities, resolve the most relevant 1C reference, and prove whether the entity grounding is stable enough for the next probe.",
primitives: ["search_business_entity", "resolve_entity_reference", "probe_coverage"],
axes,
reason:
graphAction === "search_business_entity"
? "planner_selected_entity_resolution_from_data_need_graph"
: "planner_selected_entity_resolution_recipe"
};
}
if (includesAny(combined, ["metadata_lane_choice_clarification", "resolve_next_lane"])) {
pushUnique(axes, "lane_family_choice");
return {
@ -267,8 +412,12 @@ export function planAssistantMcpDiscovery(
const recipe = recipeFor(input);
const budgetOverride = budgetOverrideFor(input, recipe);
const semanticDataNeed = toNonEmptyString(input.semanticDataNeed) ?? recipe.semanticDataNeed;
const dataNeedGraph = input.dataNeedGraph ?? null;
const reasonCodes: string[] = [];
pushReason(reasonCodes, recipe.reason);
if (dataNeedGraph) {
pushReason(reasonCodes, "planner_consumed_data_need_graph_v1");
}
if (budgetOverride.maxProbeCount) {
pushReason(reasonCodes, "planner_enabled_chunked_coverage_probe_budget");
}
@ -296,6 +445,7 @@ export function planAssistantMcpDiscovery(
policy_owner: "assistantMcpDiscoveryPlanner",
planner_status: plannerStatus,
semantic_data_need: semanticDataNeed,
data_need_graph: dataNeedGraph,
selected_chain_id: recipe.chainId,
selected_chain_summary: recipe.chainSummary,
proposed_primitives: recipe.primitives,

View File

@ -91,6 +91,36 @@ function userFacingLines(values: string[]): string[] {
}
function localizeLine(value: string): string {
if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки активности в запрошенном срезе.";
}
if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки входящих денежных поступлений в запрошенном срезе.";
}
if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки исходящих платежей и списаний в запрошенном срезе.";
}
const openScopeBidirectionalMatch = value.match(
/^1C bidirectional value-flow rows were checked for the requested counterparty scope: incoming=(found|not_found), outgoing=(found|not_found)$/i
);
if (openScopeBidirectionalMatch) {
const incoming =
openScopeBidirectionalMatch[1] === "found"
? "входящие строки найдены"
: "входящие строки не найдены";
const outgoing =
openScopeBidirectionalMatch[2] === "found"
? "исходящие строки найдены"
: "исходящие строки не найдены";
return `В 1С проверены входящие и исходящие денежные строки в запрошенном срезе: ${incoming}, ${outgoing}.`;
}
if (
/^Requested period hit the MCP row limit, but the approved monthly recovery probe budget is smaller than the required subperiod count$/i.test(
value
)
) {
return "Запрошенный период уперся в лимит строк MCP; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды.";
}
const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i);
if (counterpartyMatch) {
return `В 1С найдены строки активности по контрагенту ${counterpartyMatch[1]}.`;

View File

@ -11,6 +11,7 @@ import {
planAssistantMcpDiscovery,
type AssistantMcpDiscoveryPlannerContract
} from "./assistantMcpDiscoveryPlanner";
import type { AssistantMcpDiscoveryDataNeedGraphContract } from "./assistantMcpDiscoveryDataNeedGraph";
import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy";
export const ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION =
@ -25,6 +26,7 @@ export type AssistantMcpDiscoveryRuntimeBridgeStatus =
export interface AssistantMcpDiscoveryRuntimeBridgeInput {
semanticDataNeed?: string | null;
dataNeedGraph?: AssistantMcpDiscoveryDataNeedGraphContract | null;
turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null;
deps?: AssistantMcpDiscoveryPilotExecutorDeps;
}
@ -98,6 +100,7 @@ export async function runAssistantMcpDiscoveryRuntimeBridge(
): Promise<AssistantMcpDiscoveryRuntimeBridgeContract> {
const planner = planAssistantMcpDiscovery({
semanticDataNeed: input.semanticDataNeed,
dataNeedGraph: input.dataNeedGraph,
turnMeaning: input.turnMeaning
});
const pilot = await executeAssistantMcpDiscoveryPilot(planner, input.deps);

View File

@ -101,6 +101,7 @@ export async function runAssistantMcpDiscoveryRuntimeEntryPoint(
const bridge = await runAssistantMcpDiscoveryRuntimeBridge({
semanticDataNeed: turnInput.semantic_data_need,
dataNeedGraph: turnInput.data_need_graph,
turnMeaning: turnInput.turn_meaning_ref,
deps: input.deps
});

View File

@ -1,4 +1,8 @@
import type { AssistantMcpDiscoveryTurnMeaningRef } from "./assistantMcpDiscoveryPolicy";
import {
buildAssistantMcpDiscoveryDataNeedGraph,
type AssistantMcpDiscoveryDataNeedGraphContract
} from "./assistantMcpDiscoveryDataNeedGraph";
export const ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION =
"assistant_mcp_discovery_turn_input_v1" as const;
@ -25,6 +29,7 @@ export interface AssistantMcpDiscoveryTurnInputContract {
adapter_status: AssistantMcpDiscoveryTurnInputStatus;
should_run_discovery: boolean;
semantic_data_need: string | null;
data_need_graph: AssistantMcpDiscoveryDataNeedGraphContract | null;
turn_meaning_ref: AssistantMcpDiscoveryTurnMeaningRef | null;
source_signal: AssistantMcpDiscoveryTurnInputSource;
reason_codes: string[];
@ -114,6 +119,10 @@ function compactLower(value: unknown): string {
.trim();
}
function sameScopedName(left: string | null, right: string | null): boolean {
return Boolean(left && right && compactLower(left) === compactLower(right));
}
function candidateValue(value: unknown): string | null {
const direct = toNonEmptyString(value);
if (direct && direct !== "[object Object]") {
@ -407,6 +416,12 @@ function hasBidirectionalValueFlowSignal(text: string): boolean {
);
}
function hasValueRankingSignal(text: string): boolean {
return /(?:кто\s+больше\s+всего.*ден[её]г|больше\s+всего.*ден[её]г|прин[её]с.*ден[её]г|сам(?:ый|ая|ое|ые).*(?:доходн|прибыльн)|most.*money|highest\s+(?:revenue|payment))/iu.test(
text
);
}
function hasMonthlyAggregationSignal(text: string): boolean {
return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test(
text
@ -741,7 +756,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
const rawLifecycleSignal = hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowSignal = !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowSignal =
!rawLifecycleSignal && (hasValueFlowSignal(rawText) || rawBidirectionalValueFlowSignal);
!rawLifecycleSignal &&
(hasValueFlowSignal(rawText) || hasValueRankingSignal(rawText) || rawBidirectionalValueFlowSignal);
const rawMetadataSignal = !rawLifecycleSignal && !rawValueFlowSignal && hasMetadataSignal(rawText);
const rawEntityResolutionSignal =
!rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
@ -773,6 +789,18 @@ export function buildAssistantMcpDiscoveryTurnInput(
const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(
predecomposeEntities.counterparty,
predecomposeEntities.organization
);
const organizationMirrorsPredecomposeCounterparty = Boolean(
(rawBidirectionalValueFlowSignal || hasValueRankingSignal(rawText)) &&
(sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
predecomposeOrganizationMirrorsCounterparty)
);
const normalizedPredecomposeCounterparty = organizationMirrorsPredecomposeCounterparty
? null
: predecomposeEntities.counterparty;
const predecomposeDateScope = collectDateScope(predecomposeContract);
const followupDiscoverySeedApplicable = Boolean(
followupSeed.domain &&
@ -1038,7 +1066,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, candidate);
}
pushNormalizedEntityResolutionCandidate(entityCandidates, predecomposeEntities.counterparty);
pushNormalizedEntityResolutionCandidate(entityCandidates, normalizedPredecomposeCounterparty);
pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty);
} else {
if (groundedFollowupEntity) {
@ -1047,7 +1075,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity);
}
pushScopedEntityCandidate(entityCandidates, predecomposeEntities.counterparty, groundedFollowupEntity);
pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity);
if (!groundedFollowupEntity) {
pushScopedEntityCandidate(entityCandidates, followupSeed.counterparty, null);
pushScopedEntityCandidate(entityCandidates, followupSeed.discoveryEntity, null);
@ -1058,14 +1086,26 @@ export function buildAssistantMcpDiscoveryTurnInput(
pushUnique(entityCandidates, followupSeed.discoveryEntity);
pushUnique(entityCandidates, rawMetadataScopeHint);
}
if (valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty) {
const openScopeValueFlowWithoutCounterparty =
valueFlowSignal && !normalizedPredecomposeCounterparty && !followupSeed.counterparty;
const valueFlowOrganizationStaysScope =
openScopeValueFlowWithoutCounterparty &&
(bidirectionalValueFlowSignal || hasValueRankingSignal(rawText));
if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) {
pushUnique(entityCandidates, predecomposeEntities.organization);
pushUnique(entityCandidates, followupSeed.organization);
}
const explicitOrganizationScope =
valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty
? null
: predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization;
valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty
? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization
: null;
if (valueFlowOrganizationStaysScope && explicitOrganizationScope) {
for (let index = entityCandidates.length - 1; index >= 0; index -= 1) {
if (entityCandidates[index] === explicitOrganizationScope) {
entityCandidates.splice(index, 1);
}
}
}
const explicitDateScope = assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope;
const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = {
@ -1312,7 +1352,10 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (unsupported) {
pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn");
}
if (predecomposeEntities.counterparty) {
if (
!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
normalizedPredecomposeCounterparty
) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose");
}
if (followupSeed.counterparty) {
@ -1330,6 +1373,17 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (runDiscovery && !hasTurnMeaning) {
pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing");
}
const dataNeedGraph =
runDiscovery && hasTurnMeaning
? buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed,
rawUtterance: rawSignalSourceText,
turnMeaning: cleanTurnMeaning
})
: null;
if (dataNeedGraph) {
pushReason(reasonCodes, "mcp_discovery_data_need_graph_built");
}
return {
schema_version: ASSISTANT_MCP_DISCOVERY_TURN_INPUT_SCHEMA_VERSION,
@ -1337,6 +1391,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
adapter_status: !runDiscovery ? "not_applicable" : hasTurnMeaning ? "ready" : "needs_more_context",
should_run_discovery: runDiscovery,
semantic_data_need: runDiscovery ? semanticDataNeed : null,
data_need_graph: dataNeedGraph,
turn_meaning_ref: runDiscovery && hasTurnMeaning ? cleanTurnMeaning : null,
source_signal: sourceSignal,
reason_codes: reasonCodes

View File

@ -222,6 +222,74 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.must_not_claim).toContain("Do not claim rows were checked when mcp_execution_performed=false.");
});
it("asks for organization rather than counterparty when a ranked value-flow ask already has the period", async () => {
const planner = 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: "2020"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([]));
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
expect(draft.answer_mode).toBe("needs_clarification");
expect(draft.headline).toContain("ranking");
expect(draft.next_step_line).toContain("организацию");
expect(draft.next_step_line).not.toContain("Уточните контрагента");
});
it("asks for organization rather than counterparty on open bidirectional comparison when only the period is known", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_comparison_incoming_vs_outgoing"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([]));
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
expect(draft.answer_mode).toBe("needs_clarification");
expect(draft.headline).toContain("входящий и исходящий");
expect(draft.next_step_line).toContain("организацию");
expect(draft.next_step_line).not.toContain("Уточните контрагента");
});
it("asks for an explicit lane choice when mixed metadata ambiguity cannot continue on a neutral follow-up", async () => {
const planner = planAssistantMcpDiscovery({
turnMeaning: {

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { buildAssistantMcpDiscoveryDataNeedGraph } from "../src/services/assistantMcpDiscoveryDataNeedGraph";
describe("assistant MCP discovery data need graph", () => {
it("builds a monthly bidirectional value-flow graph from grounded turn meaning", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "counterparty value-flow evidence",
rawUtterance: "какое нетто по деньгам с SVK за 2020 год по месяцам",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
asked_aggregation_axis: "month",
explicit_entity_candidates: ["SVK"],
explicit_date_scope: "2020"
}
});
expect(result.business_fact_family).toBe("value_flow");
expect(result.action_family).toBe("net_value_flow");
expect(result.aggregation_need).toBe("by_month");
expect(result.time_scope_need).toBe("explicit_period");
expect(result.comparison_need).toBe("incoming_vs_outgoing");
expect(result.proof_expectation).toBe("coverage_checked_fact");
expect(result.clarification_gaps).toEqual([]);
expect(result.decomposition_candidates).toEqual([
"resolve_entity_reference",
"collect_incoming_movements",
"collect_outgoing_movements",
"aggregate_by_month",
"probe_coverage"
]);
expect(result.forbidden_overclaim_flags).toContain("no_unchecked_fact_totals");
});
it("marks metadata lane choice as a clarification-required graph", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "metadata lane clarification",
rawUtterance: "давай дальше",
turnMeaning: {
asked_domain_family: "metadata",
asked_action_family: "resolve_next_lane",
explicit_entity_candidates: ["SVK"],
unsupported_but_understood_family: "metadata_lane_choice_clarification"
}
});
expect(result.business_fact_family).toBe("schema_surface");
expect(result.clarification_gaps).toEqual(["lane_family_choice"]);
expect(result.proof_expectation).toBe("clarification_required");
});
it("keeps entity search as an entity-grounding graph", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "entity discovery evidence",
rawUtterance: "найди в 1С контрагента Группа СВК",
turnMeaning: {
asked_domain_family: "entity_resolution",
asked_action_family: "search_business_entity",
explicit_entity_candidates: ["Группа СВК"]
}
});
expect(result.business_fact_family).toBe("entity_grounding");
expect(result.subject_candidates).toEqual(["Группа СВК"]);
expect(result.proof_expectation).toBe("entity_grounding");
expect(result.decomposition_candidates).toEqual([
"search_business_entity",
"resolve_entity_reference",
"probe_coverage"
]);
expect(result.forbidden_overclaim_flags).toContain("no_unresolved_entity_claim");
});
it("treats top-value wording as a ranking ask rather than a missing-subject fact ask", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "counterparty value-flow evidence",
rawUtterance: "кто больше всего принес денег в 2020",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_date_scope: "2020"
}
});
expect(result.business_fact_family).toBe("value_flow");
expect(result.ranking_need).toBe("top_desc");
expect(result.clarification_gaps).toEqual([]);
expect(result.decomposition_candidates).toEqual([
"collect_scoped_movements",
"aggregate_ranked_axis_values",
"probe_coverage"
]);
expect(result.reason_codes).toContain("data_need_graph_ranking_top_desc");
});
it("treats incoming-vs-outgoing comparison as an open-scope value need rather than a missing-subject fact ask", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "counterparty value-flow evidence",
rawUtterance: "что больше: входящие или исходящие деньги за 2020 год?",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020"
}
});
expect(result.business_fact_family).toBe("value_flow");
expect(result.comparison_need).toBe("incoming_vs_outgoing");
expect(result.clarification_gaps).toEqual([]);
expect(result.decomposition_candidates).toEqual([
"collect_incoming_movements",
"collect_outgoing_movements",
"probe_coverage"
]);
expect(result.reason_codes).toContain("data_need_graph_comparison_incoming_vs_outgoing");
});
});

View File

@ -4,6 +4,27 @@ import { planAssistantMcpDiscovery } from "../src/services/assistantMcpDiscovery
describe("assistant MCP discovery planner", () => {
it("builds a catalog-compatible value-flow discovery plan from current turn meaning", () => {
const result = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: ["SVK"],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: [
"resolve_entity_reference",
"collect_scoped_movements",
"aggregate_checked_amounts",
"probe_coverage"
],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
@ -25,8 +46,10 @@ describe("assistant MCP discovery planner", () => {
expect(result.required_axes).toEqual(["counterparty", "period", "aggregate_axis", "amount", "coverage_target"]);
expect(result.catalog_review.review_status).toBe("catalog_compatible");
expect(result.discovery_plan.answer_may_use_raw_model_claims).toBe(false);
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.discovery_plan.execution_budget.max_probe_count).toBe(30);
expect(result.reason_codes).toContain("planner_enabled_chunked_coverage_probe_budget");
expect(result.reason_codes).toContain("planner_consumed_data_need_graph_v1");
});
it("keeps a value-flow plan in clarification state when period axis is missing", () => {
@ -91,6 +114,22 @@ describe("assistant MCP discovery planner", () => {
it("builds a movement discovery plan without aggregating value-flow totals", () => {
const result = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: ["SVK"],
business_fact_family: "movement_evidence",
action_family: "list_movements",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["resolve_entity_reference", "fetch_scoped_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built"]
},
turnMeaning: {
asked_domain_family: "movements",
asked_action_family: "list_movements",
@ -107,7 +146,192 @@ describe("assistant MCP discovery planner", () => {
expect(result.proposed_primitives).toEqual(["resolve_entity_reference", "query_movements", "probe_coverage"]);
expect(result.proposed_primitives).not.toContain("aggregate_by_axis");
expect(result.required_axes).toEqual(["counterparty", "period", "coverage_target"]);
expect(result.reason_codes).toContain("planner_selected_movement_recipe");
expect(result.reason_codes).toContain("planner_selected_movement_from_data_need_graph");
});
it("can select value-flow chain from data need graph even when turn meaning family is still under-specified", () => {
const result = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: ["SVK"],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: "by_month",
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: [
"resolve_entity_reference",
"collect_incoming_movements",
"collect_outgoing_movements",
"aggregate_by_month",
"probe_coverage"
],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built"]
},
turnMeaning: {
explicit_entity_candidates: ["SVK"],
explicit_date_scope: "2020",
asked_aggregation_axis: "month"
}
});
expect(result.planner_status).toBe("ready_for_execution");
expect(result.selected_chain_id).toBe("value_flow");
expect(result.proposed_primitives).toEqual([
"resolve_entity_reference",
"query_movements",
"aggregate_by_axis",
"probe_coverage"
]);
expect(result.required_axes).toEqual([
"counterparty",
"period",
"aggregate_axis",
"amount",
"coverage_target",
"calendar_month"
]);
expect(result.reason_codes).toContain("planner_selected_monthly_value_flow_from_data_need_graph");
});
it("does not collapse a ranking-shaped value graph into entity-resolution just because no subject is preselected", () => {
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: "2020"
}
});
expect(result.planner_status).toBe("needs_clarification");
expect(result.selected_chain_id).toBe("value_flow_ranking");
expect(result.proposed_primitives).toEqual(["query_movements", "aggregate_by_axis", "probe_coverage"]);
expect(result.required_axes).toEqual(["period", "aggregate_axis", "amount", "coverage_target"]);
expect(result.catalog_review.review_status).toBe("needs_more_axes");
expect(result.reason_codes).toContain("planner_selected_top_ranked_value_flow_from_data_need_graph");
expect(result.selected_chain_id).not.toBe("entity_resolution");
});
it("keeps ranked value-flow ready for execution once checked period and organization are known", () => {
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: "2020",
explicit_organization_scope: "ООО Альтернатива Плюс"
}
});
expect(result.planner_status).toBe("ready_for_execution");
expect(result.selected_chain_id).toBe("value_flow_ranking");
expect(result.proposed_primitives).toEqual(["query_movements", "aggregate_by_axis", "probe_coverage"]);
expect(result.required_axes).toEqual(["organization", "period", "aggregate_axis", "amount", "coverage_target"]);
expect(result.catalog_review.review_status).toBe("catalog_compatible");
expect(result.reason_codes).toContain("planner_selected_top_ranked_value_flow_from_data_need_graph");
});
it("does not collapse incoming-vs-outgoing comparison into entity-resolution when no counterparty is preselected", () => {
const result = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_comparison_incoming_vs_outgoing"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020"
}
});
expect(result.planner_status).toBe("needs_clarification");
expect(result.selected_chain_id).toBe("value_flow_comparison");
expect(result.proposed_primitives).toEqual(["query_movements", "probe_coverage"]);
expect(result.required_axes).toEqual(["period", "amount", "coverage_target"]);
expect(result.reason_codes).toContain("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph");
expect(result.selected_chain_id).not.toBe("entity_resolution");
});
it("keeps bidirectional comparison ready for execution once checked period and organization are known", () => {
const result = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_comparison_incoming_vs_outgoing"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020",
explicit_organization_scope: "ООО Альтернатива Плюс"
}
});
expect(result.planner_status).toBe("ready_for_execution");
expect(result.selected_chain_id).toBe("value_flow_comparison");
expect(result.proposed_primitives).toEqual(["query_movements", "probe_coverage"]);
expect(result.required_axes).toEqual(["organization", "period", "amount", "coverage_target"]);
expect(result.catalog_review.review_status).toBe("catalog_compatible");
expect(result.reason_codes).toContain("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph");
});
it("builds an inference-safe lifecycle plan with evidence explanation", () => {

View File

@ -397,4 +397,41 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).toBeNull();
expect(candidate.eligible_for_future_hot_runtime).toBe(false);
});
it("localizes open-scope bidirectional comparison scope and probe-limit wording without contour garbage", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline:
"\u041f\u043e \u0434\u0430\u043d\u043d\u044b\u043c 1\u0421 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0441\u0442\u0440\u043e\u043a\u0438 \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0445 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439; \u043d\u0435\u0442\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043a\u0430\u043a \u0440\u0430\u0441\u0447\u0435\u0442 \u043f\u043e \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u043c \u0441\u0442\u0440\u043e\u043a\u0430\u043c \u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u043d\u043e\u043c\u0443 \u043f\u0435\u0440\u0438\u043e\u0434\u0443.",
confirmed_lines: [
"1C bidirectional value-flow rows were checked for the requested counterparty scope: incoming=found, outgoing=found"
],
inference_lines: [],
unknown_lines: [],
limitation_lines: [
"Requested period hit the MCP row limit, but the approved monthly recovery probe budget is smaller than the required subperiod count"
],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain(
"\u0412 1\u0421 \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u044b \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0432 \u0437\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u043e\u043c \u0441\u0440\u0435\u0437\u0435"
);
expect(candidate.reply_text).toContain(
"\u0417\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u044b\u0439 \u043f\u0435\u0440\u0438\u043e\u0434 \u0443\u043f\u0435\u0440\u0441\u044f \u0432 \u043b\u0438\u043c\u0438\u0442 \u0441\u0442\u0440\u043e\u043a MCP"
);
expect(candidate.reply_text).not.toContain(
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0441\u043a\u043e\u043c\u0443 \u043a\u043e\u043d\u0442\u0443\u0440\u0443"
);
expect(candidate.reply_text).not.toContain("Requested period hit the MCP row limit");
});
});

View File

@ -13,6 +13,30 @@ function buildDeps(rows: Array<Record<string, unknown>>, error: string | null =
};
}
function buildBidirectionalDeps(
incomingRows: Array<Record<string, unknown>>,
outgoingRows: Array<Record<string, unknown>>
) {
return {
executeAddressMcpQuery: vi
.fn()
.mockResolvedValueOnce({
fetched_rows: incomingRows.length,
matched_rows: incomingRows.length,
raw_rows: incomingRows,
rows: incomingRows,
error: null
})
.mockResolvedValueOnce({
fetched_rows: outgoingRows.length,
matched_rows: outgoingRows.length,
raw_rows: outgoingRows,
rows: outgoingRows,
error: null
})
};
}
describe("assistant MCP discovery runtime bridge", () => {
it("composes planner, pilot executor, and answer draft without wiring the hot runtime", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
@ -51,6 +75,166 @@ describe("assistant MCP discovery runtime bridge", () => {
expect(result.answer_draft.next_step_line).toContain("Уточните контрагента");
});
it("keeps ranked value-flow in clarification without asking for a counterparty when only the period is known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
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: "2020"
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
expect(result.requires_user_clarification).toBe(true);
expect(result.pilot.mcp_execution_performed).toBe(false);
expect(result.planner.selected_chain_id).toBe("value_flow_ranking");
expect(result.answer_draft.headline).toContain("ranking");
expect(result.answer_draft.next_step_line).toContain("организацию");
expect(result.answer_draft.next_step_line).not.toContain("Уточните контрагента");
});
it("produces a bounded ranked value-flow answer when period and organization are known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
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: "2020",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
deps: buildDeps([
{ Period: "2020-01-10T00:00:00", Amount: 1200, Counterparty: "СВК-А" },
{ Period: "2020-03-11T00:00:00", Amount: 800, Counterparty: "СВК-Б" },
{ Period: "2020-05-12T00:00:00", Amount: 900, Counterparty: "СВК-А" }
])
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.planner.selected_chain_id).toBe("value_flow_ranking");
expect(result.pilot.derived_ranked_value_flow?.ranked_values[0]).toMatchObject({
axis_value: "СВК-А",
total_amount: 2100
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("СВК-А");
});
it("keeps open bidirectional comparison in clarification without asking for a counterparty when only the period is known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_comparison_incoming_vs_outgoing"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020"
},
deps: buildDeps([])
});
expect(result.bridge_status).toBe("needs_clarification");
expect(result.requires_user_clarification).toBe(true);
expect(result.pilot.mcp_execution_performed).toBe(false);
expect(result.planner.selected_chain_id).toBe("value_flow_comparison");
expect(result.answer_draft.headline).toContain("входящий и исходящий");
expect(result.answer_draft.next_step_line).toContain("организацию");
expect(result.answer_draft.next_step_line).not.toContain("Уточните контрагента");
});
it("produces a bounded bidirectional comparison answer when period and organization are known", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_comparison_incoming_vs_outgoing"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_date_scope: "2020",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
deps: buildBidirectionalDeps(
[
{ Period: "2020-01-10T00:00:00", Amount: 3200, Counterparty: "СВК-А" },
{ Period: "2020-04-11T00:00:00", Amount: 1800, Counterparty: "СВК-Б" }
],
[{ Period: "2020-02-12T00:00:00", Amount: 1400, Counterparty: "СВК-А" }]
)
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.planner.selected_chain_id).toBe("value_flow_comparison");
expect(result.pilot.derived_bidirectional_value_flow).toMatchObject({
period_scope: "2020",
incoming_customer_revenue: {
total_amount: 5000
},
outgoing_supplier_payout: {
total_amount: 1400
}
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("получили");
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("заплатили");
});
it("keeps document-ready plans bounded when the pilot finds no confirmed rows", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
turnMeaning: {

View File

@ -13,6 +13,30 @@ function buildDeps(rows: Array<Record<string, unknown>>, error: string | null =
};
}
function buildBidirectionalDeps(
incomingRows: Array<Record<string, unknown>>,
outgoingRows: Array<Record<string, unknown>>
) {
return {
executeAddressMcpQuery: vi
.fn()
.mockResolvedValueOnce({
fetched_rows: incomingRows.length,
matched_rows: incomingRows.length,
raw_rows: incomingRows,
rows: incomingRows,
error: null
})
.mockResolvedValueOnce({
fetched_rows: outgoingRows.length,
matched_rows: outgoingRows.length,
raw_rows: outgoingRows,
rows: outgoingRows,
error: null
})
};
}
function buildMetadataDeps(rows: Array<Record<string, unknown>>, error: string | null = null) {
return {
executeAddressMcpMetadata: vi.fn(async () => ({
@ -240,4 +264,93 @@ describe("assistant MCP discovery runtime entry point", () => {
expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_document_evidence_query_documents_v1");
expect(result.bridge?.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference");
});
it("runs raw incoming-vs-outgoing comparison as an open-scope value-flow chain without inventing a counterparty", async () => {
const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
predecomposeContract: {
entities: { organization: "ООО Альтернатива Плюс" },
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
},
deps: buildBidirectionalDeps(
[
{ Period: "2020-01-15T00:00:00", Amount: 2500, Counterparty: "Клиент-А" },
{ Period: "2020-06-20T00:00:00", Amount: 1000, Counterparty: "Клиент-Б" }
],
[{ Period: "2020-02-18T00:00:00", Amount: 900, Counterparty: "Поставщик-А" }]
)
});
expect(result.entry_status).toBe("bridge_executed");
expect(result.discovery_attempted).toBe(true);
expect(result.turn_input.data_need_graph?.comparison_need).toBe("incoming_vs_outgoing");
expect(result.turn_input.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
});
expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_comparison");
expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_bidirectional_value_flow_query_movements_v1");
expect(result.bridge?.answer_draft.confirmed_lines.join("\n")).toContain("получили");
expect(result.bridge?.answer_draft.confirmed_lines.join("\n")).toContain("заплатили");
});
it.skip("keeps mirrored predecompose organization and counterparty out of the subject lane for open comparison", async () => {
const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
predecomposeContract: {
entities: {
counterparty: "ООО Альтернатива Плюс",
organization: "ООО Альтернатива Плюс"
},
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
},
deps: buildBidirectionalDeps(
[{ Period: "2020-01-15T00:00:00", Amount: 2500, Counterparty: "Клиент-А" }],
[{ Period: "2020-02-18T00:00:00", Amount: 900, Counterparty: "Поставщик-А" }]
)
});
expect(result.entry_status).toBe("bridge_executed");
expect(result.turn_input.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
});
expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.turn_input.data_need_graph?.subject_candidates).toEqual([]);
expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_comparison");
});
it.skip("keeps mirrored predecompose organization and counterparty out of the subject lane for open comparison (utf8-safe)", async () => {
const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
predecomposeContract: {
entities: {
counterparty: orgName,
organization: orgName
},
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
},
deps: buildBidirectionalDeps(
[{ Period: "2020-01-15T00:00:00", Amount: 2500, Counterparty: "\u041a\u043b\u0438\u0435\u043d\u0442-\u0410" }],
[{ Period: "2020-02-18T00:00:00", Amount: 900, Counterparty: "\u041f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a-\u0410" }]
)
});
expect(result.entry_status).toBe("bridge_executed");
expect(result.turn_input.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_organization_scope: orgName,
explicit_date_scope: "2020"
});
expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.turn_input.data_need_graph?.subject_candidates).toEqual([]);
expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_comparison");
});
});

View File

@ -21,6 +21,8 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.data_need_graph?.time_scope_need).toBe("explicit_period");
expect(result.turn_meaning_ref?.explicit_entity_candidates).toEqual(["SVK", "Группа СВК"]);
expect(result.turn_meaning_ref?.explicit_organization_scope).toBe("Альтернатива");
expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2020");
@ -160,6 +162,8 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.should_run_discovery).toBe(true);
expect(result.source_signal).toBe("raw_text");
expect(result.semantic_data_need).toBe("1C metadata evidence");
expect(result.data_need_graph?.business_fact_family).toBe("schema_surface");
expect(result.data_need_graph?.decomposition_candidates).toEqual(["inspect_metadata_surface"]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "metadata",
asked_action_family: "inspect_fields",
@ -198,6 +202,8 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.should_run_discovery).toBe(true);
expect(result.source_signal).toBe("raw_text");
expect(result.semantic_data_need).toBe("entity discovery evidence");
expect(result.data_need_graph?.business_fact_family).toBe("entity_grounding");
expect(result.data_need_graph?.subject_candidates).toEqual(["Группа СВК"]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "entity_resolution",
asked_action_family: "search_business_entity",
@ -1256,4 +1262,119 @@ describe("assistant MCP discovery turn input adapter", () => {
"\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u043d\u0430\u0439\u0442\u0438 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430 \u0441 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c '\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a'"
);
});
it("marks top-value wording as a ranking data need without inventing a missing subject gap", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "кто больше всего принес денег в 2020"
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.data_need_graph?.ranking_need).toBe("top_desc");
expect(result.data_need_graph?.clarification_gaps).toEqual([]);
expect(result.data_need_graph?.decomposition_candidates).toEqual([
"collect_scoped_movements",
"aggregate_ranked_axis_values",
"probe_coverage"
]);
});
it("keeps organization as scope for open bidirectional comparison wording instead of inventing a subject candidate", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
predecomposeContract: {
entities: { organization: "ООО Альтернатива Плюс" },
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.data_need_graph?.comparison_need).toBe("incoming_vs_outgoing");
expect(result.data_need_graph?.clarification_gaps).toEqual([]);
});
it("drops organization-shaped assistant-turn entity pollution when open comparison already has explicit organization scope", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
assistantTurnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: [{ value: "ООО Альтернатива Плюс" }],
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
},
predecomposeContract: {
entities: { counterparty: "ООО Альтернатива Плюс", organization: "ООО Альтернатива Плюс" },
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
}
});
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.data_need_graph?.comparison_need).toBe("incoming_vs_outgoing");
});
it.skip("treats mirrored predecompose organization and counterparty as organization scope for open comparison", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
predecomposeContract: {
entities: {
counterparty: "ООО Альтернатива Плюс",
organization: "ООО Альтернатива Плюс"
},
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
}
});
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.data_need_graph?.subject_candidates).toEqual([]);
expect(result.data_need_graph?.comparison_need).toBe("incoming_vs_outgoing");
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_predecompose");
});
it.skip("treats mirrored predecompose organization and counterparty as organization scope for open comparison (utf8-safe)", () => {
const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
predecomposeContract: {
entities: {
counterparty: orgName,
organization: orgName
},
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
}
});
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_organization_scope: orgName,
explicit_date_scope: "2020"
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.data_need_graph?.subject_candidates).toEqual([]);
expect(result.data_need_graph?.comparison_need).toBe("incoming_vs_outgoing");
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_predecompose");
});
});