diff --git a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md index a70d14e..6693deb 100644 --- a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md +++ b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md @@ -1304,6 +1304,47 @@ Module progress: - Big Block 5 MCP Semantic Data Agent: `100%`. +## Progress Update - 2026-04-21 MCP Discovery Yearly Coverage Recovery Through Monthly Probes + +The twentieth implementation slice of Big Block 5 closes the first yearly-coverage proof gap that remained inside guarded MCP discovery value-flow answers. + +Before this slice the assistant could already: + +- answer incoming counterparty turnover totals through guarded MCP discovery; +- answer outgoing supplier payout totals through guarded MCP discovery; +- compose bidirectional net totals and month-by-month net lines from the same evidence. + +But the broad yearly value-flow probe still stopped at the first MCP row limit. For dense 2020 counterparty activity this meant the assistant answered honestly, yet underpowered: user-facing text still said the outgoing side hit `100 из 100`, even though the missing coverage was recoverable through bounded subperiod probes rather than impossible. + +New behavior: + +- the discovery planner now grants a larger but still bounded probe budget for explicit yearly value-flow and monthly-aggregation questions; +- the pilot executor first runs the broad yearly value-flow probe and, only when that broad probe hits the row limit on an explicit year window, automatically recovers coverage through month-by-month 1C subprobes; +- recovered subperiod rows are stitched into one coverage-aware result instead of being exposed as separate runtime fragments; +- guarded answer drafting keeps the answer honest: it no longer claims a partial year when monthly recovery fully proves the requested period, but it still states the derivation basis and keeps "outside the checked period is not proven" boundaries intact. + +Replay result: + +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun14` passed `8/8`, final status `accepted`; +- the supplier payout step now answers the 2020 `Группа СВК` question with `43 763 351,53 руб.`, based on `299 из 299` recovered rows from `2020-01-09` through `2020-12-25`; +- the bidirectional net step now answers with `47 628 853,03 руб.` received, `43 763 351,53 руб.` paid, and `3 865 501,50 руб.` net in our favor for the requested 2020 window; +- the monthly net step keeps the month-by-month shape from January through December 2020 and no longer inherits the old fake partiality caused by the first broad outgoing probe hitting the row cap; +- user-facing text explains that coverage was restored through monthly 1C checks after the broad probe hit the row limit, without leaking `planner_`, `pilot_`, `runtime_`, `query_documents`, or `query_movements`. + +Validation: + +- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryResponsePolicy.test.ts assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts assistantAddressLaneResponseRuntimeAdapter.test.ts assistantDeepTurnResponseRuntimeAdapter.test.ts assistantLivingChatRuntimeAdapter.test.ts assistantAddressOrchestrationRuntimeAdapter.test.ts assistantDebugPayloadAssembler.test.ts` passed `91/91`; +- `npm run build` passed; +- `python scripts/domain_truth_harness.py run-live --spec docs/orchestration/address_truth_harness_phase19_mcp_discovery_response_gate.json --output-dir artifacts/domain_runs/address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun14 --timeout-seconds 240` passed `8/8`, final status `accepted`. + +Known remaining boundary: + +- this still does not make Qwen3 an unrestricted self-navigating 1C agent across arbitrary registers and schemas; richer follow-up drilldown over the recovered rows, broader self-navigation, and evidence-safe agentic exploration remain future work beyond this first completed guarded MCP semantic data block. + +Module progress: + +- Big Block 5 MCP Semantic Data Agent: `100%`. + ## Execution Rule Do not implement this plan as: diff --git a/docs/orchestration/address_truth_harness_phase19_mcp_discovery_response_gate.json b/docs/orchestration/address_truth_harness_phase19_mcp_discovery_response_gate.json index 0d8fbb6..0e22fa9 100644 --- a/docs/orchestration/address_truth_harness_phase19_mcp_discovery_response_gate.json +++ b/docs/orchestration/address_truth_harness_phase19_mcp_discovery_response_gate.json @@ -67,6 +67,8 @@ "forbidden_answer_patterns": [ "(?i)точный маршрут.*не подключ", "(?i)не буду подставлять", + "(?i)100\\s+из\\s+100", + "(?i)лимит.*строк", "(?i)query_documents", "(?i)query_movements", "(?i)runtime_", @@ -95,6 +97,8 @@ "forbidden_answer_patterns": [ "(?i)точный маршрут.*не подключ", "(?i)не буду подставлять", + "(?i)100\\s+из\\s+100", + "(?i)лимит.*строк", "(?i)query_documents", "(?i)query_movements", "(?i)runtime_", @@ -125,6 +129,8 @@ "forbidden_answer_patterns": [ "(?i)точный маршрут.*не подключ", "(?i)не буду подставлять", + "(?i)100\\s+из\\s+100", + "(?i)лимит.*строк", "(?i)query_documents", "(?i)query_movements", "(?i)runtime_", diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 25f9d62..831c4c2 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -168,6 +168,149 @@ function queryResultToProbeResult(primitiveId, result) { limitation: result.error }; } +function toCoverageAwareQueryResult(result, options = {}) { + if (!result) { + return null; + } + return { + ...result, + coverage_limited_by_probe_limit: options.coverageLimitedByProbeLimit ?? false, + coverage_recovered_by_period_chunking: options.coverageRecoveredByPeriodChunking ?? false, + period_chunking_granularity: options.periodChunkingGranularity ?? null, + period_chunk_count: options.periodChunkCount ?? 0 + }; +} +function monthWindowsForYear(year) { + const result = []; + for (let month = 0; month < 12; month += 1) { + const start = new Date(Date.UTC(Number(year), month, 1)); + const end = new Date(Date.UTC(Number(year), month + 1, 0)); + result.push({ + period_from: `${start.getUTCFullYear()}-${String(start.getUTCMonth() + 1).padStart(2, "0")}-${String(start.getUTCDate()).padStart(2, "0")}`, + period_to: `${end.getUTCFullYear()}-${String(end.getUTCMonth() + 1).padStart(2, "0")}-${String(end.getUTCDate()).padStart(2, "0")}` + }); + } + return result; +} +function periodWindowsForDateScope(dateScope) { + const yearMatch = dateScope?.match(/^(\d{4})$/); + if (yearMatch) { + return monthWindowsForYear(yearMatch[1]); + } + return []; +} +function mergeCoverageAwareQueryResults(results, options) { + const rawRows = results.flatMap((item) => item.raw_rows); + const rows = results.flatMap((item) => item.rows); + const errors = results.map((item) => toNonEmptyString(item.error)).filter((item) => Boolean(item)); + return { + fetched_rows: results.reduce((sum, item) => sum + item.fetched_rows, 0), + matched_rows: results.reduce((sum, item) => sum + item.matched_rows, 0), + raw_rows: rawRows, + rows, + error: errors[0] ?? null, + coverage_limited_by_probe_limit: options.coverageLimitedByProbeLimit, + coverage_recovered_by_period_chunking: options.coverageRecoveredByPeriodChunking, + period_chunking_granularity: options.periodChunkingGranularity, + period_chunk_count: options.periodChunkCount + }; +} +async function executeCoverageAwareValueFlowQuery(input) { + const queryLimitations = []; + const probeResults = []; + let executedProbeCount = 0; + const broadRecipePlan = input.recipePlanBuilder(input.baseFilters); + const broadResult = await input.deps.executeAddressMcpQuery({ + query: broadRecipePlan.query, + limit: broadRecipePlan.limit, + account_scope: broadRecipePlan.account_scope + }); + executedProbeCount += 1; + probeResults.push(queryResultToProbeResult(input.primitiveId, broadResult)); + const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= input.maxRowsPerProbe; + if (broadResult.error) { + pushUnique(queryLimitations, broadResult.error); + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: false + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + const periodWindows = periodWindowsForDateScope(input.dateScope); + if (!broadCoverageLimited || periodWindows.length === 0) { + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: broadCoverageLimited + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + const requiredChunkProbeCount = periodWindows.length; + if (executedProbeCount + requiredChunkProbeCount > input.maxProbeCount) { + pushUnique(queryLimitations, "Requested period hit the MCP row limit, but the approved monthly recovery probe budget is smaller than the required subperiod count"); + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: true + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + const chunkResults = []; + let anyChunkLimited = false; + let anyChunkError = false; + for (const window of periodWindows) { + const chunkFilters = { + ...input.baseFilters, + period_from: window.period_from, + period_to: window.period_to + }; + const chunkPlan = input.recipePlanBuilder(chunkFilters); + const chunkResult = await input.deps.executeAddressMcpQuery({ + query: chunkPlan.query, + limit: chunkPlan.limit, + account_scope: chunkPlan.account_scope + }); + executedProbeCount += 1; + probeResults.push(queryResultToProbeResult(input.primitiveId, chunkResult)); + if (chunkResult.error) { + anyChunkError = true; + pushUnique(queryLimitations, chunkResult.error); + continue; + } + if (chunkResult.matched_rows >= input.maxRowsPerProbe) { + anyChunkLimited = true; + } + chunkResults.push(chunkResult); + } + if (chunkResults.length === 0 && anyChunkError) { + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: true + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + return { + result: mergeCoverageAwareQueryResults(chunkResults, { + coverageLimitedByProbeLimit: anyChunkLimited || anyChunkError, + coverageRecoveredByPeriodChunking: true, + periodChunkingGranularity: "month", + periodChunkCount: periodWindows.length + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; +} function summarizeLifecycleRows(result) { if (result.error) { return null; @@ -184,6 +327,9 @@ function summarizeValueFlowRows(result) { if (result.fetched_rows <= 0) { return "0 MCP value-flow rows fetched"; } + if (result.coverage_recovered_by_period_chunking && result.period_chunking_granularity === "month") { + return `${result.period_chunk_count} monthly MCP value-flow probes fetched ${result.fetched_rows} rows total, ${result.matched_rows} matched value-flow scope after the broad probe hit the row limit`; + } return `${result.fetched_rows} MCP value-flow rows fetched, ${result.matched_rows} matched value-flow scope`; } function rowDateValue(row) { @@ -370,7 +516,7 @@ function deriveBidirectionalValueFlowMonthBreakdown(input) { }; }); } -function deriveValueFlow(result, counterparty, periodScope, direction, probeLimit, aggregationAxis) { +function deriveValueFlow(result, counterparty, periodScope, direction, aggregationAxis) { if (!result || result.error || result.matched_rows <= 0) { return null; } @@ -401,12 +547,14 @@ function deriveValueFlow(result, counterparty, periodScope, direction, probeLimi total_amount_human_ru: formatAmountHumanRu(totalAmount), first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, - coverage_limited_by_probe_limit: result.matched_rows >= probeLimit, + 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, monthly_breakdown: deriveValueFlowMonthBreakdown(result, aggregationAxis), inference_basis: "sum_of_confirmed_1c_value_flow_rows" }; } -function deriveValueFlowSideSummary(result, probeLimit) { +function deriveValueFlowSideSummary(result) { if (!result || result.error || result.matched_rows <= 0) { return { rows_matched: 0, @@ -415,7 +563,9 @@ function deriveValueFlowSideSummary(result, probeLimit) { total_amount_human_ru: formatAmountHumanRu(0), first_movement_date: null, latest_movement_date: null, - coverage_limited_by_probe_limit: false + coverage_limited_by_probe_limit: false, + coverage_recovered_by_period_chunking: false, + period_chunking_granularity: null }; } let totalAmount = 0; @@ -438,12 +588,14 @@ function deriveValueFlowSideSummary(result, probeLimit) { total_amount_human_ru: formatAmountHumanRu(totalAmount), first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, - coverage_limited_by_probe_limit: result.matched_rows >= probeLimit + 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 }; } function deriveBidirectionalValueFlow(input) { - const incoming = deriveValueFlowSideSummary(input.incomingResult, input.probeLimit); - const outgoing = deriveValueFlowSideSummary(input.outgoingResult, input.probeLimit); + const incoming = deriveValueFlowSideSummary(input.incomingResult); + const outgoing = deriveValueFlowSideSummary(input.outgoingResult); if (incoming.rows_with_amount <= 0 && outgoing.rows_with_amount <= 0) { return null; } @@ -458,6 +610,8 @@ function deriveBidirectionalValueFlow(input) { net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)), net_direction: netDirectionFromAmount(netAmount), coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, + coverage_recovered_by_period_chunking: incoming.coverage_recovered_by_period_chunking || outgoing.coverage_recovered_by_period_chunking, + period_chunking_granularity: incoming.period_chunking_granularity ?? outgoing.period_chunking_granularity ?? null, monthly_breakdown: deriveBidirectionalValueFlowMonthBreakdown({ incomingResult: input.incomingResult, outgoingResult: input.outgoingResult, @@ -474,10 +628,14 @@ function summarizeBidirectionalValueFlowRows(input) { } const incomingSummary = incoming?.error ? "incoming value-flow query failed" - : `${incoming?.fetched_rows ?? 0} incoming value-flow rows fetched, ${incoming?.matched_rows ?? 0} matched`; + : incoming?.coverage_recovered_by_period_chunking && incoming.period_chunking_granularity === "month" + ? `${incoming.period_chunk_count} monthly incoming value-flow probes fetched ${incoming.fetched_rows} rows total, ${incoming.matched_rows} matched` + : `${incoming?.fetched_rows ?? 0} incoming value-flow rows fetched, ${incoming?.matched_rows ?? 0} matched`; const outgoingSummary = outgoing?.error ? "outgoing supplier-payout query failed" - : `${outgoing?.fetched_rows ?? 0} outgoing supplier-payout rows fetched, ${outgoing?.matched_rows ?? 0} matched`; + : outgoing?.coverage_recovered_by_period_chunking && outgoing.period_chunking_granularity === "month" + ? `${outgoing.period_chunk_count} monthly outgoing supplier-payout probes fetched ${outgoing.fetched_rows} rows total, ${outgoing.matched_rows} matched` + : `${outgoing?.fetched_rows ?? 0} outgoing supplier-payout rows fetched, ${outgoing?.matched_rows ?? 0} matched`; return `${incomingSummary}; ${outgoingSummary}`; } function buildLifecycleConfirmedFacts(result, counterparty) { @@ -539,6 +697,9 @@ function buildValueFlowInferredFacts(derived) { else { facts.push("Counterparty value-flow total was calculated from confirmed 1C movement rows"); } + if (derived.coverage_recovered_by_period_chunking && derived.period_chunking_granularity === "month") { + facts.push("Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit"); + } if (derived.aggregation_axis === "month" && derived.monthly_breakdown.length > 0) { facts.push("Counterparty monthly value-flow breakdown was grouped by month over confirmed 1C movement rows"); } @@ -549,6 +710,9 @@ function buildBidirectionalValueFlowInferredFacts(derived) { return []; } const facts = ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"]; + if (derived.coverage_recovered_by_period_chunking && derived.period_chunking_granularity === "month") { + facts.push("Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit"); + } if (derived.aggregation_axis === "month" && derived.monthly_breakdown.length > 0) { facts.push("Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows"); } @@ -706,36 +870,50 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { }; } pushReason(reasonCodes, "pilot_bidirectional_value_flow_recipes_selected"); - const incomingRecipePlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(incomingSelection.selected_recipe, filters); - const outgoingRecipePlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(outgoingSelection.selected_recipe, filters); for (const step of dryRun.execution_steps) { if (step.primitive_id !== "query_movements") { skippedPrimitives.push(step.primitive_id); probeResults.push(skippedProbeResult(step, "pilot_bidirectional_value_flow_uses_two_query_movements_and_derives_net")); continue; } - incomingResult = await deps.executeAddressMcpQuery({ - query: incomingRecipePlan.query, - limit: incomingRecipePlan.limit, - account_scope: incomingRecipePlan.account_scope + const incomingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => (0, addressRecipeCatalog_1.buildAddressRecipePlan)(incomingSelection.selected_recipe, scopedFilters), + baseFilters: filters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps }); - outgoingResult = await deps.executeAddressMcpQuery({ - query: outgoingRecipePlan.query, - limit: outgoingRecipePlan.limit, - account_scope: outgoingRecipePlan.account_scope + const outgoingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => (0, addressRecipeCatalog_1.buildAddressRecipePlan)(outgoingSelection.selected_recipe, scopedFilters), + baseFilters: filters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps }); + incomingResult = incomingExecution.result; + outgoingResult = outgoingExecution.result; pushUnique(executedPrimitives, step.primitive_id); - probeResults.push(queryResultToProbeResult(step.primitive_id, incomingResult)); - probeResults.push(queryResultToProbeResult(step.primitive_id, outgoingResult)); - if (incomingResult.error) { - pushUnique(queryLimitations, incomingResult.error); + probeResults.push(...incomingExecution.probe_results, ...outgoingExecution.probe_results); + for (const limitation of [...incomingExecution.query_limitations, ...outgoingExecution.query_limitations]) { + pushUnique(queryLimitations, limitation); + } + if (incomingResult?.error) { pushReason(reasonCodes, "pilot_bidirectional_incoming_query_movements_mcp_error"); } - if (outgoingResult.error) { - pushUnique(queryLimitations, outgoingResult.error); + if (outgoingResult?.error) { pushReason(reasonCodes, "pilot_bidirectional_outgoing_query_movements_mcp_error"); } - if (!incomingResult.error || !outgoingResult.error) { + if (incomingResult?.coverage_recovered_by_period_chunking) { + pushReason(reasonCodes, "pilot_bidirectional_incoming_monthly_period_chunking_recovered_coverage"); + } + if (outgoingResult?.coverage_recovered_by_period_chunking) { + pushReason(reasonCodes, "pilot_bidirectional_outgoing_monthly_period_chunking_recovered_coverage"); + } + if (!incomingResult?.error || !outgoingResult?.error) { pushReason(reasonCodes, "pilot_bidirectional_query_movements_mcp_executed"); } } @@ -745,7 +923,6 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { outgoingResult, counterparty, periodScope: dateScope, - probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe, aggregationAxis }); if (derivedBidirectionalValueFlow) { @@ -810,30 +987,39 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { pushReason(reasonCodes, valueFlowProfile.direction === "outgoing_supplier_payout" ? "pilot_supplier_payout_recipe_selected" : "pilot_customer_revenue_recipe_selected"); - const recipePlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(selection.selected_recipe, filters); for (const step of dryRun.execution_steps) { if (step.primitive_id !== "query_movements") { skippedPrimitives.push(step.primitive_id); probeResults.push(skippedProbeResult(step, "pilot_value_flow_uses_query_movements_and_derives_aggregate")); continue; } - queryResult = await deps.executeAddressMcpQuery({ - query: recipePlan.query, - limit: recipePlan.limit, - account_scope: recipePlan.account_scope + const execution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => (0, addressRecipeCatalog_1.buildAddressRecipePlan)(selection.selected_recipe, scopedFilters), + baseFilters: filters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps }); - executedPrimitives.push(step.primitive_id); - probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); - if (queryResult.error) { - pushUnique(queryLimitations, queryResult.error); + queryResult = execution.result; + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(...execution.probe_results); + for (const limitation of execution.query_limitations) { + pushUnique(queryLimitations, limitation); + } + if (queryResult?.error) { pushReason(reasonCodes, "pilot_query_movements_mcp_error"); } else { pushReason(reasonCodes, "pilot_query_movements_mcp_executed"); } + if (queryResult?.coverage_recovered_by_period_chunking) { + pushReason(reasonCodes, "pilot_monthly_period_chunking_recovered_coverage"); + } } const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null; - const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope, valueFlowProfile.direction, planner.discovery_plan.execution_budget.max_rows_per_probe, aggregationAxis); + const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope, valueFlowProfile.direction, aggregationAxis); if (derivedValueFlow) { pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows"); if (aggregationAxis === "month" && derivedValueFlow.monthly_breakdown.length > 0) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index f163863..df34f05 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -55,6 +55,24 @@ function addScopeAxes(axes, meaning) { function includesAny(text, tokens) { return tokens.some((token) => text.includes(token)); } +function isYearDateScope(meaning) { + return /^\d{4}$/.test(toNonEmptyString(meaning?.explicit_date_scope) ?? ""); +} +function budgetOverrideFor(input, recipe) { + const meaning = input.turnMeaning ?? null; + const requestedAggregationAxis = aggregationAxis(meaning); + const isValueFlowRecipe = recipe.semanticDataNeed === "counterparty value-flow evidence" && + recipe.primitives.includes("query_movements"); + if (!isValueFlowRecipe) { + return {}; + } + if (requestedAggregationAxis === "month" || isYearDateScope(meaning)) { + return { + maxProbeCount: 30 + }; + } + return {}; +} function recipeFor(input) { const meaning = input.turnMeaning ?? null; const domain = lower(meaning?.asked_domain_family); @@ -136,14 +154,19 @@ function statusFrom(plan, review) { } function planAssistantMcpDiscovery(input) { const recipe = recipeFor(input); + const budgetOverride = budgetOverrideFor(input, recipe); const semanticDataNeed = toNonEmptyString(input.semanticDataNeed) ?? recipe.semanticDataNeed; const reasonCodes = []; pushReason(reasonCodes, recipe.reason); + if (budgetOverride.maxProbeCount) { + pushReason(reasonCodes, "planner_enabled_chunked_coverage_probe_budget"); + } const plan = (0, assistantMcpDiscoveryPolicy_1.buildAssistantMcpDiscoveryPlan)({ semanticDataNeed, turnMeaning: input.turnMeaning, proposedPrimitives: recipe.primitives, - requiredAxes: recipe.axes + requiredAxes: recipe.axes, + maxProbeCount: budgetOverride.maxProbeCount }); const review = (0, assistantMcpCatalogIndex_1.reviewAssistantMcpDiscoveryPlanAgainstCatalog)(plan); const plannerStatus = statusFrom(plan, review); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js index 9b9c98f..8ea5d37 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -21,7 +21,7 @@ const DEFAULT_DISCOVERY_BUDGET = { max_probe_count: 3, max_rows_per_probe: 100 }; -const MAX_PROBE_COUNT = 6; +const MAX_PROBE_COUNT = 36; const MAX_ROWS_PER_PROBE = 500; const ALLOWED_PRIMITIVE_SET = new Set(exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES); function toNonEmptyString(value) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 5a7f568..d93a106 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -139,6 +139,12 @@ function localizeLine(value) { if (/^Full all-time bidirectional value-flow is not proven without an explicit checked period$/i.test(value)) { return "Полный двусторонний денежный поток за все время без явно проверенного периода не подтвержден."; } + if (/^Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit$/i.test(value)) { + return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк."; + } + if (/^Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit$/i.test(value)) { + return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк хотя бы по одной стороне."; + } return value; } function section(title, lines) { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index f01643a..a6ce78a 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -62,6 +62,8 @@ export interface AssistantMcpDiscoveryDerivedValueFlow { first_movement_date: string | null; latest_movement_date: string | null; coverage_limited_by_probe_limit: boolean; + coverage_recovered_by_period_chunking: boolean; + period_chunking_granularity: AssistantMcpDiscoveryAggregationAxis | null; monthly_breakdown: AssistantMcpDiscoveryValueFlowMonthBucket[]; inference_basis: "sum_of_confirmed_1c_value_flow_rows"; } @@ -74,6 +76,8 @@ export interface AssistantMcpDiscoveryValueFlowSideSummary { first_movement_date: string | null; latest_movement_date: string | null; coverage_limited_by_probe_limit: boolean; + coverage_recovered_by_period_chunking: boolean; + period_chunking_granularity: AssistantMcpDiscoveryAggregationAxis | null; } export interface AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket { @@ -99,10 +103,26 @@ export interface AssistantMcpDiscoveryDerivedBidirectionalValueFlow { net_amount_human_ru: string; net_direction: AssistantMcpDiscoveryNetDirection; coverage_limited_by_probe_limit: boolean; + coverage_recovered_by_period_chunking: boolean; + period_chunking_granularity: AssistantMcpDiscoveryAggregationAxis | null; monthly_breakdown: AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket[]; inference_basis: "incoming_minus_outgoing_confirmed_1c_value_flow_rows"; } +interface AssistantMcpDiscoveryCoverageAwareQueryResult extends AddressMcpQueryExecutorResult { + coverage_limited_by_probe_limit: boolean; + coverage_recovered_by_period_chunking: boolean; + period_chunking_granularity: AssistantMcpDiscoveryAggregationAxis | null; + period_chunk_count: number; +} + +interface AssistantMcpDiscoveryCoverageAwareQueryExecution { + result: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + probe_results: AssistantMcpDiscoveryProbeResult[]; + query_limitations: string[]; + executed_probe_count: number; +} + export type AssistantMcpDiscoveryPilotScope = | "counterparty_lifecycle_query_documents_v1" | "counterparty_value_flow_query_movements_v1" @@ -330,6 +350,193 @@ function queryResultToProbeResult( }; } +function toCoverageAwareQueryResult( + result: AddressMcpQueryExecutorResult | null, + options: { + coverageLimitedByProbeLimit?: boolean; + coverageRecoveredByPeriodChunking?: boolean; + periodChunkingGranularity?: AssistantMcpDiscoveryAggregationAxis | null; + periodChunkCount?: number; + } = {} +): AssistantMcpDiscoveryCoverageAwareQueryResult | null { + if (!result) { + return null; + } + return { + ...result, + coverage_limited_by_probe_limit: options.coverageLimitedByProbeLimit ?? false, + coverage_recovered_by_period_chunking: options.coverageRecoveredByPeriodChunking ?? false, + period_chunking_granularity: options.periodChunkingGranularity ?? null, + period_chunk_count: options.periodChunkCount ?? 0 + }; +} + +function monthWindowsForYear(year: string): Array<{ period_from: string; period_to: string }> { + const result: Array<{ period_from: string; period_to: string }> = []; + for (let month = 0; month < 12; month += 1) { + const start = new Date(Date.UTC(Number(year), month, 1)); + const end = new Date(Date.UTC(Number(year), month + 1, 0)); + result.push({ + period_from: `${start.getUTCFullYear()}-${String(start.getUTCMonth() + 1).padStart(2, "0")}-${String(start.getUTCDate()).padStart(2, "0")}`, + period_to: `${end.getUTCFullYear()}-${String(end.getUTCMonth() + 1).padStart(2, "0")}-${String(end.getUTCDate()).padStart(2, "0")}` + }); + } + return result; +} + +function periodWindowsForDateScope(dateScope: string | null): Array<{ period_from: string; period_to: string }> { + const yearMatch = dateScope?.match(/^(\d{4})$/); + if (yearMatch) { + return monthWindowsForYear(yearMatch[1]); + } + return []; +} + +function mergeCoverageAwareQueryResults( + results: AddressMcpQueryExecutorResult[], + options: { + coverageLimitedByProbeLimit: boolean; + coverageRecoveredByPeriodChunking: boolean; + periodChunkingGranularity: AssistantMcpDiscoveryAggregationAxis | null; + periodChunkCount: number; + } +): AssistantMcpDiscoveryCoverageAwareQueryResult { + const rawRows = results.flatMap((item) => item.raw_rows); + const rows = results.flatMap((item) => item.rows); + const errors = results.map((item) => toNonEmptyString(item.error)).filter((item): item is string => Boolean(item)); + return { + fetched_rows: results.reduce((sum, item) => sum + item.fetched_rows, 0), + matched_rows: results.reduce((sum, item) => sum + item.matched_rows, 0), + raw_rows: rawRows, + rows, + error: errors[0] ?? null, + coverage_limited_by_probe_limit: options.coverageLimitedByProbeLimit, + coverage_recovered_by_period_chunking: options.coverageRecoveredByPeriodChunking, + period_chunking_granularity: options.periodChunkingGranularity, + period_chunk_count: options.periodChunkCount + }; +} + +async function executeCoverageAwareValueFlowQuery(input: { + primitiveId: string; + recipePlanBuilder: (filters: AddressFilterSet) => { + query: string; + limit: number; + account_scope?: string[]; + }; + baseFilters: AddressFilterSet; + dateScope: string | null; + maxProbeCount: number; + maxRowsPerProbe: number; + deps: AssistantMcpDiscoveryPilotExecutorDeps; +}): Promise { + const queryLimitations: string[] = []; + const probeResults: AssistantMcpDiscoveryProbeResult[] = []; + let executedProbeCount = 0; + + const broadRecipePlan = input.recipePlanBuilder(input.baseFilters); + const broadResult = await input.deps.executeAddressMcpQuery({ + query: broadRecipePlan.query, + limit: broadRecipePlan.limit, + account_scope: broadRecipePlan.account_scope + }); + executedProbeCount += 1; + probeResults.push(queryResultToProbeResult(input.primitiveId, broadResult)); + const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= input.maxRowsPerProbe; + + if (broadResult.error) { + pushUnique(queryLimitations, broadResult.error); + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: false + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + + const periodWindows = periodWindowsForDateScope(input.dateScope); + if (!broadCoverageLimited || periodWindows.length === 0) { + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: broadCoverageLimited + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + + const requiredChunkProbeCount = periodWindows.length; + if (executedProbeCount + requiredChunkProbeCount > input.maxProbeCount) { + pushUnique( + queryLimitations, + "Requested period hit the MCP row limit, but the approved monthly recovery probe budget is smaller than the required subperiod count" + ); + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: true + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + + const chunkResults: AddressMcpQueryExecutorResult[] = []; + let anyChunkLimited = false; + let anyChunkError = false; + + for (const window of periodWindows) { + const chunkFilters: AddressFilterSet = { + ...input.baseFilters, + period_from: window.period_from, + period_to: window.period_to + }; + const chunkPlan = input.recipePlanBuilder(chunkFilters); + const chunkResult = await input.deps.executeAddressMcpQuery({ + query: chunkPlan.query, + limit: chunkPlan.limit, + account_scope: chunkPlan.account_scope + }); + executedProbeCount += 1; + probeResults.push(queryResultToProbeResult(input.primitiveId, chunkResult)); + if (chunkResult.error) { + anyChunkError = true; + pushUnique(queryLimitations, chunkResult.error); + continue; + } + if (chunkResult.matched_rows >= input.maxRowsPerProbe) { + anyChunkLimited = true; + } + chunkResults.push(chunkResult); + } + + if (chunkResults.length === 0 && anyChunkError) { + return { + result: toCoverageAwareQueryResult(broadResult, { + coverageLimitedByProbeLimit: true + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; + } + + return { + result: mergeCoverageAwareQueryResults(chunkResults, { + coverageLimitedByProbeLimit: anyChunkLimited || anyChunkError, + coverageRecoveredByPeriodChunking: true, + periodChunkingGranularity: "month", + periodChunkCount: periodWindows.length + }), + probe_results: probeResults, + query_limitations: queryLimitations, + executed_probe_count: executedProbeCount + }; +} + function summarizeLifecycleRows(result: AddressMcpQueryExecutorResult): string | null { if (result.error) { return null; @@ -340,13 +547,16 @@ function summarizeLifecycleRows(result: AddressMcpQueryExecutorResult): string | return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`; } -function summarizeValueFlowRows(result: AddressMcpQueryExecutorResult): string | null { +function summarizeValueFlowRows(result: AssistantMcpDiscoveryCoverageAwareQueryResult): string | null { if (result.error) { return null; } if (result.fetched_rows <= 0) { return "0 MCP value-flow rows fetched"; } + if (result.coverage_recovered_by_period_chunking && result.period_chunking_granularity === "month") { + return `${result.period_chunk_count} monthly MCP value-flow probes fetched ${result.fetched_rows} rows total, ${result.matched_rows} matched value-flow scope after the broad probe hit the row limit`; + } return `${result.fetched_rows} MCP value-flow rows fetched, ${result.matched_rows} matched value-flow scope`; } @@ -482,7 +692,7 @@ function formatAmountHumanRu(amount: number): string { } function deriveValueFlowMonthBreakdown( - result: AddressMcpQueryExecutorResult | null, + result: AssistantMcpDiscoveryCoverageAwareQueryResult | null, aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null ): AssistantMcpDiscoveryValueFlowMonthBucket[] { if (!result || result.error || aggregationAxis !== "month") { @@ -512,8 +722,8 @@ function deriveValueFlowMonthBreakdown( } function deriveBidirectionalValueFlowMonthBreakdown(input: { - incomingResult: AddressMcpQueryExecutorResult | null; - outgoingResult: AddressMcpQueryExecutorResult | null; + incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null; }): AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket[] { if (input.aggregationAxis !== "month") { @@ -557,11 +767,10 @@ function deriveBidirectionalValueFlowMonthBreakdown(input: { } function deriveValueFlow( - result: AddressMcpQueryExecutorResult | null, + result: AssistantMcpDiscoveryCoverageAwareQueryResult | null, counterparty: string | null, periodScope: string | null, direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"], - probeLimit: number, aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null ): AssistantMcpDiscoveryDerivedValueFlow | null { if (!result || result.error || result.matched_rows <= 0) { @@ -594,15 +803,16 @@ function deriveValueFlow( total_amount_human_ru: formatAmountHumanRu(totalAmount), first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, - coverage_limited_by_probe_limit: result.matched_rows >= probeLimit, + 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, monthly_breakdown: deriveValueFlowMonthBreakdown(result, aggregationAxis), inference_basis: "sum_of_confirmed_1c_value_flow_rows" }; } function deriveValueFlowSideSummary( - result: AddressMcpQueryExecutorResult | null, - probeLimit: number + result: AssistantMcpDiscoveryCoverageAwareQueryResult | null ): AssistantMcpDiscoveryValueFlowSideSummary { if (!result || result.error || result.matched_rows <= 0) { return { @@ -612,7 +822,9 @@ function deriveValueFlowSideSummary( total_amount_human_ru: formatAmountHumanRu(0), first_movement_date: null, latest_movement_date: null, - coverage_limited_by_probe_limit: false + coverage_limited_by_probe_limit: false, + coverage_recovered_by_period_chunking: false, + period_chunking_granularity: null }; } @@ -636,20 +848,21 @@ function deriveValueFlowSideSummary( total_amount_human_ru: formatAmountHumanRu(totalAmount), first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, - coverage_limited_by_probe_limit: result.matched_rows >= probeLimit + 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 }; } function deriveBidirectionalValueFlow(input: { - incomingResult: AddressMcpQueryExecutorResult | null; - outgoingResult: AddressMcpQueryExecutorResult | null; + incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; counterparty: string | null; periodScope: string | null; - probeLimit: number; aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null; }): AssistantMcpDiscoveryDerivedBidirectionalValueFlow | null { - const incoming = deriveValueFlowSideSummary(input.incomingResult, input.probeLimit); - const outgoing = deriveValueFlowSideSummary(input.outgoingResult, input.probeLimit); + const incoming = deriveValueFlowSideSummary(input.incomingResult); + const outgoing = deriveValueFlowSideSummary(input.outgoingResult); if (incoming.rows_with_amount <= 0 && outgoing.rows_with_amount <= 0) { return null; } @@ -665,6 +878,10 @@ function deriveBidirectionalValueFlow(input: { net_direction: netDirectionFromAmount(netAmount), coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, + coverage_recovered_by_period_chunking: + incoming.coverage_recovered_by_period_chunking || outgoing.coverage_recovered_by_period_chunking, + period_chunking_granularity: + incoming.period_chunking_granularity ?? outgoing.period_chunking_granularity ?? null, monthly_breakdown: deriveBidirectionalValueFlowMonthBreakdown({ incomingResult: input.incomingResult, outgoingResult: input.outgoingResult, @@ -675,8 +892,8 @@ function deriveBidirectionalValueFlow(input: { } function summarizeBidirectionalValueFlowRows(input: { - incomingResult: AddressMcpQueryExecutorResult | null; - outgoingResult: AddressMcpQueryExecutorResult | null; + incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; + outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; }): string | null { const incoming = input.incomingResult; const outgoing = input.outgoingResult; @@ -685,10 +902,14 @@ function summarizeBidirectionalValueFlowRows(input: { } const incomingSummary = incoming?.error ? "incoming value-flow query failed" - : `${incoming?.fetched_rows ?? 0} incoming value-flow rows fetched, ${incoming?.matched_rows ?? 0} matched`; + : incoming?.coverage_recovered_by_period_chunking && incoming.period_chunking_granularity === "month" + ? `${incoming.period_chunk_count} monthly incoming value-flow probes fetched ${incoming.fetched_rows} rows total, ${incoming.matched_rows} matched` + : `${incoming?.fetched_rows ?? 0} incoming value-flow rows fetched, ${incoming?.matched_rows ?? 0} matched`; const outgoingSummary = outgoing?.error ? "outgoing supplier-payout query failed" - : `${outgoing?.fetched_rows ?? 0} outgoing supplier-payout rows fetched, ${outgoing?.matched_rows ?? 0} matched`; + : outgoing?.coverage_recovered_by_period_chunking && outgoing.period_chunking_granularity === "month" + ? `${outgoing.period_chunk_count} monthly outgoing supplier-payout probes fetched ${outgoing.fetched_rows} rows total, ${outgoing.matched_rows} matched` + : `${outgoing?.fetched_rows ?? 0} outgoing supplier-payout rows fetched, ${outgoing?.matched_rows ?? 0} matched`; return `${incomingSummary}; ${outgoingSummary}`; } @@ -704,7 +925,7 @@ function buildLifecycleConfirmedFacts(result: AddressMcpQueryExecutorResult, cou } function buildValueFlowConfirmedFacts( - result: AddressMcpQueryExecutorResult, + result: AssistantMcpDiscoveryCoverageAwareQueryResult, counterparty: string | null, direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"] ): string[] { @@ -760,6 +981,11 @@ function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueF } else { facts.push("Counterparty value-flow total was calculated from confirmed 1C movement rows"); } + if (derived.coverage_recovered_by_period_chunking && derived.period_chunking_granularity === "month") { + facts.push( + "Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit" + ); + } if (derived.aggregation_axis === "month" && derived.monthly_breakdown.length > 0) { facts.push("Counterparty monthly value-flow breakdown was grouped by month over confirmed 1C movement rows"); } @@ -773,6 +999,11 @@ function buildBidirectionalValueFlowInferredFacts( return []; } const facts = ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"]; + if (derived.coverage_recovered_by_period_chunking && derived.period_chunking_granularity === "month") { + facts.push( + "Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit" + ); + } if (derived.aggregation_axis === "month" && derived.monthly_breakdown.length > 0) { facts.push("Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows"); } @@ -933,12 +1164,12 @@ export async function executeAssistantMcpDiscoveryPilot( const aggregationAxis = aggregationAxisForPlanner(planner); if (valueFlowPilotEligible) { - let queryResult: AddressMcpQueryExecutorResult | null = null; + let queryResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null; const filters = buildValueFlowFilters(planner); const valueFlowProfile = valueFlowPilotProfile(planner); if (valueFlowProfile.direction === "bidirectional_net_value_flow") { - let incomingResult: AddressMcpQueryExecutorResult | null = null; - let outgoingResult: AddressMcpQueryExecutorResult | null = null; + let incomingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null; + let outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null; const incomingSelection = selectAddressRecipe("customer_revenue_and_payments", filters); const outgoingSelection = selectAddressRecipe("supplier_payouts_profile", filters); if (!incomingSelection.selected_recipe || !outgoingSelection.selected_recipe) { @@ -965,8 +1196,6 @@ export async function executeAssistantMcpDiscoveryPilot( } pushReason(reasonCodes, "pilot_bidirectional_value_flow_recipes_selected"); - const incomingRecipePlan = buildAddressRecipePlan(incomingSelection.selected_recipe, filters); - const outgoingRecipePlan = buildAddressRecipePlan(outgoingSelection.selected_recipe, filters); for (const step of dryRun.execution_steps) { if (step.primitive_id !== "query_movements") { @@ -977,28 +1206,44 @@ export async function executeAssistantMcpDiscoveryPilot( continue; } - incomingResult = await deps.executeAddressMcpQuery({ - query: incomingRecipePlan.query, - limit: incomingRecipePlan.limit, - account_scope: incomingRecipePlan.account_scope + const incomingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => buildAddressRecipePlan(incomingSelection.selected_recipe!, scopedFilters), + baseFilters: filters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps }); - outgoingResult = await deps.executeAddressMcpQuery({ - query: outgoingRecipePlan.query, - limit: outgoingRecipePlan.limit, - account_scope: outgoingRecipePlan.account_scope + const outgoingExecution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => buildAddressRecipePlan(outgoingSelection.selected_recipe!, scopedFilters), + baseFilters: filters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps }); + incomingResult = incomingExecution.result; + outgoingResult = outgoingExecution.result; pushUnique(executedPrimitives, step.primitive_id); - probeResults.push(queryResultToProbeResult(step.primitive_id, incomingResult)); - probeResults.push(queryResultToProbeResult(step.primitive_id, outgoingResult)); - if (incomingResult.error) { - pushUnique(queryLimitations, incomingResult.error); + probeResults.push(...incomingExecution.probe_results, ...outgoingExecution.probe_results); + for (const limitation of [...incomingExecution.query_limitations, ...outgoingExecution.query_limitations]) { + pushUnique(queryLimitations, limitation); + } + if (incomingResult?.error) { pushReason(reasonCodes, "pilot_bidirectional_incoming_query_movements_mcp_error"); } - if (outgoingResult.error) { - pushUnique(queryLimitations, outgoingResult.error); + if (outgoingResult?.error) { pushReason(reasonCodes, "pilot_bidirectional_outgoing_query_movements_mcp_error"); } - if (!incomingResult.error || !outgoingResult.error) { + if (incomingResult?.coverage_recovered_by_period_chunking) { + pushReason(reasonCodes, "pilot_bidirectional_incoming_monthly_period_chunking_recovered_coverage"); + } + if (outgoingResult?.coverage_recovered_by_period_chunking) { + pushReason(reasonCodes, "pilot_bidirectional_outgoing_monthly_period_chunking_recovered_coverage"); + } + if (!incomingResult?.error || !outgoingResult?.error) { pushReason(reasonCodes, "pilot_bidirectional_query_movements_mcp_executed"); } } @@ -1009,7 +1254,6 @@ export async function executeAssistantMcpDiscoveryPilot( outgoingResult, counterparty, periodScope: dateScope, - probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe, aggregationAxis }); if (derivedBidirectionalValueFlow) { @@ -1080,26 +1324,35 @@ export async function executeAssistantMcpDiscoveryPilot( : "pilot_customer_revenue_recipe_selected" ); - const recipePlan = buildAddressRecipePlan(selection.selected_recipe, filters); for (const step of dryRun.execution_steps) { if (step.primitive_id !== "query_movements") { skippedPrimitives.push(step.primitive_id); probeResults.push(skippedProbeResult(step, "pilot_value_flow_uses_query_movements_and_derives_aggregate")); continue; } - queryResult = await deps.executeAddressMcpQuery({ - query: recipePlan.query, - limit: recipePlan.limit, - account_scope: recipePlan.account_scope + const execution = await executeCoverageAwareValueFlowQuery({ + primitiveId: step.primitive_id, + recipePlanBuilder: (scopedFilters) => buildAddressRecipePlan(selection.selected_recipe!, scopedFilters), + baseFilters: filters, + dateScope, + maxProbeCount: planner.discovery_plan.execution_budget.max_probe_count, + maxRowsPerProbe: planner.discovery_plan.execution_budget.max_rows_per_probe, + deps }); - executedPrimitives.push(step.primitive_id); - probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); - if (queryResult.error) { - pushUnique(queryLimitations, queryResult.error); + queryResult = execution.result; + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(...execution.probe_results); + for (const limitation of execution.query_limitations) { + pushUnique(queryLimitations, limitation); + } + if (queryResult?.error) { pushReason(reasonCodes, "pilot_query_movements_mcp_error"); } else { pushReason(reasonCodes, "pilot_query_movements_mcp_executed"); } + if (queryResult?.coverage_recovered_by_period_chunking) { + pushReason(reasonCodes, "pilot_monthly_period_chunking_recovered_coverage"); + } } const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null; @@ -1108,7 +1361,6 @@ export async function executeAssistantMcpDiscoveryPilot( counterparty, dateScope, valueFlowProfile.direction, - planner.discovery_plan.execution_budget.max_rows_per_probe, aggregationAxis ); if (derivedValueFlow) { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index 0a70f7d..389ddc8 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -37,6 +37,10 @@ interface PlannerRecipe { reason: string; } +interface PlannerBudgetOverride { + maxProbeCount?: number; +} + function toNonEmptyString(value: unknown): string | null { if (value === null || value === undefined) { return null; @@ -96,6 +100,27 @@ function includesAny(text: string, tokens: string[]): boolean { return tokens.some((token) => text.includes(token)); } +function isYearDateScope(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): boolean { + return /^\d{4}$/.test(toNonEmptyString(meaning?.explicit_date_scope) ?? ""); +} + +function budgetOverrideFor(input: AssistantMcpDiscoveryPlannerInput, recipe: PlannerRecipe): PlannerBudgetOverride { + const meaning = input.turnMeaning ?? null; + const requestedAggregationAxis = aggregationAxis(meaning); + const isValueFlowRecipe = + recipe.semanticDataNeed === "counterparty value-flow evidence" && + recipe.primitives.includes("query_movements"); + if (!isValueFlowRecipe) { + return {}; + } + if (requestedAggregationAxis === "month" || isYearDateScope(meaning)) { + return { + maxProbeCount: 30 + }; + } + return {}; +} + function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const meaning = input.turnMeaning ?? null; const domain = lower(meaning?.asked_domain_family); @@ -190,15 +215,20 @@ export function planAssistantMcpDiscovery( input: AssistantMcpDiscoveryPlannerInput ): AssistantMcpDiscoveryPlannerContract { const recipe = recipeFor(input); + const budgetOverride = budgetOverrideFor(input, recipe); const semanticDataNeed = toNonEmptyString(input.semanticDataNeed) ?? recipe.semanticDataNeed; const reasonCodes: string[] = []; pushReason(reasonCodes, recipe.reason); + if (budgetOverride.maxProbeCount) { + pushReason(reasonCodes, "planner_enabled_chunked_coverage_probe_budget"); + } const plan = buildAssistantMcpDiscoveryPlan({ semanticDataNeed, turnMeaning: input.turnMeaning, proposedPrimitives: recipe.primitives, - requiredAxes: recipe.axes + requiredAxes: recipe.axes, + maxProbeCount: budgetOverride.maxProbeCount }); const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan); const plannerStatus = statusFrom(plan, review); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index 3b3a1c7..fa44ce1 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -102,7 +102,7 @@ const DEFAULT_DISCOVERY_BUDGET: AssistantMcpDiscoveryExecutionBudget = { max_rows_per_probe: 100 }; -const MAX_PROBE_COUNT = 6; +const MAX_PROBE_COUNT = 36; const MAX_ROWS_PER_PROBE = 500; const ALLOWED_PRIMITIVE_SET = new Set(ASSISTANT_MCP_DISCOVERY_PRIMITIVES); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 9718ddb..7791925 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -173,6 +173,20 @@ function localizeLine(value: string): string { if (/^Full all-time bidirectional value-flow is not proven without an explicit checked period$/i.test(value)) { return "Полный двусторонний денежный поток за все время без явно проверенного периода не подтвержден."; } + if ( + /^Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit$/i.test( + value + ) + ) { + return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк."; + } + if ( + /^Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit$/i.test( + value + ) + ) { + return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк хотя бы по одной стороне."; + } return value; } diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 01d9c35..d1f7688 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -233,6 +233,45 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.reason_codes).toContain("answer_contains_monthly_breakdown"); }); + it("keeps recovered yearly coverage out of the unknown block and explains the recovery as bounded inference", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + } + }); + const broadRows = Array.from({ length: 100 }, (_, index) => ({ + Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, + Amount: 10, + Counterparty: "SVK" + })); + const monthlyResults = Array.from({ length: 12 }, (_, index) => ({ + rows: [ + { + Period: `2020-${String(index + 1).padStart(2, "0")}-05T00:00:00`, + Amount: (index + 1) * 100, + Counterparty: "SVK" + } + ] + })); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([{ rows: broadRows }, ...monthlyResults]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.inference_lines).toContain( + "Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit" + ); + expect(draft.unknown_lines).not.toContain( + "Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached" + ); + }); + it("does not leak primitive names or query text into user-facing lines", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index 06aa061..0f43bc9 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -199,6 +199,56 @@ describe("assistant MCP discovery pilot executor", () => { ); }); + it("recovers yearly value-flow coverage by splitting a limited broad probe into monthly subprobes", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + } + }); + const broadRows = Array.from({ length: 100 }, (_, index) => ({ + Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, + Amount: 10, + Counterparty: "SVK" + })); + const monthlyResults = Array.from({ length: 12 }, (_, index) => ({ + rows: [ + { + Period: `2020-${String(index + 1).padStart(2, "0")}-05T00:00:00`, + Amount: (index + 1) * 100, + Counterparty: "SVK" + } + ] + })); + + const result = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([{ rows: broadRows }, ...monthlyResults]) + ); + + expect(result.derived_value_flow).toMatchObject({ + value_flow_direction: "outgoing_supplier_payout", + coverage_limited_by_probe_limit: false, + coverage_recovered_by_period_chunking: true, + period_chunking_granularity: "month", + rows_matched: 12, + rows_with_amount: 12, + total_amount: 7800, + first_movement_date: "2020-01-05", + latest_movement_date: "2020-12-05" + }); + expect(result.evidence.inferred_facts).toContain( + "Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit" + ); + expect(result.evidence.unknown_facts).not.toContain( + "Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached" + ); + expect(result.reason_codes).toContain("pilot_monthly_period_chunking_recovered_coverage"); + }); + it("executes bidirectional value-flow queries and derives guarded net cash flow", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { @@ -319,6 +369,69 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.reason_codes).toContain("pilot_derived_bidirectional_monthly_breakdown_from_confirmed_rows"); }); + it("recovers bidirectional yearly coverage when one side is rebuilt from monthly subprobes", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" + } + }); + const outgoingBroadRows = Array.from({ length: 100 }, (_, index) => ({ + Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, + Amount: 10, + Counterparty: "SVK" + })); + const outgoingMonthlyResults = Array.from({ length: 12 }, (_, index) => ({ + rows: [ + { + Period: `2020-${String(index + 1).padStart(2, "0")}-10T00:00:00`, + Amount: (index + 1) * 50, + Counterparty: "SVK" + } + ] + })); + const deps = buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 10000, Counterparty: "SVK" }, + { Period: "2020-02-20T00:00:00", Amount: 10000, Counterparty: "SVK" } + ] + }, + { rows: outgoingBroadRows }, + ...outgoingMonthlyResults + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.derived_bidirectional_value_flow).toMatchObject({ + coverage_limited_by_probe_limit: false, + coverage_recovered_by_period_chunking: true, + period_chunking_granularity: "month", + net_amount: 16100, + incoming_customer_revenue: { + total_amount: 20000, + coverage_limited_by_probe_limit: false + }, + outgoing_supplier_payout: { + total_amount: 3900, + coverage_limited_by_probe_limit: false, + coverage_recovered_by_period_chunking: true, + period_chunking_granularity: "month" + } + }); + expect(result.evidence.inferred_facts).toContain( + "Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit" + ); + expect(result.evidence.unknown_facts).not.toContain( + "Complete requested-period coverage for bidirectional value-flow is not proven because at least one MCP discovery probe row limit was reached" + ); + expect(result.reason_codes).toContain("pilot_bidirectional_outgoing_monthly_period_chunking_recovered_coverage"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(14); + }); + it("keeps non-lifecycle ready plans unsupported until a dedicated pilot exists", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index 8f40b06..99e33cc 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -23,6 +23,8 @@ 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.discovery_plan.execution_budget.max_probe_count).toBe(30); + expect(result.reason_codes).toContain("planner_enabled_chunked_coverage_probe_budget"); }); it("keeps a value-flow plan in clarification state when period axis is missing", () => { @@ -67,6 +69,7 @@ describe("assistant MCP discovery planner", () => { "calendar_month" ]); expect(result.reason_codes).toContain("planner_selected_monthly_value_flow_recipe"); + expect(result.discovery_plan.execution_budget.max_probe_count).toBe(30); }); it("builds a document discovery plan without falling back to movement primitives", () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts index 324f475..3c11d3d 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPolicy.test.ts @@ -26,7 +26,7 @@ describe("assistant MCP discovery policy", () => { expect(plan.rejected_primitives).toEqual(["drop_database"]); expect(plan.requires_evidence_gate).toBe(true); expect(plan.answer_may_use_raw_model_claims).toBe(false); - expect(plan.execution_budget).toEqual({ max_probe_count: 6, max_rows_per_probe: 500 }); + expect(plan.execution_budget).toEqual({ max_probe_count: 36, max_rows_per_probe: 500 }); expect(plan.reason_codes).toContain("model_proposed_unregistered_mcp_primitive"); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 516c508..35da20a 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -182,6 +182,33 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("Counterparty monthly net value-flow breakdown"); }); + it("localizes recovered coverage facts without leaking broad-probe wording", () => { + 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: "РџРѕ данным 1РЎ найдены строки исходящих платежей/списаний; СЃСѓРјРјСѓ РјРѕР¶РЅРѕ называть только РІ рамках проверенного периода Рё найденных строк.", + confirmed_lines: ["1C supplier-payout rows were found for counterparty SVK"], + inference_lines: [ + "Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit" + ], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("Покрытие запрошенного периода восстановлено помесячными проверками 1С"); + expect(candidate.reply_text).not.toContain("broad probe hit the row limit"); + }); + it("returns not applicable when discovery was skipped for an exact supported route", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate({ schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",