From 4baa54fe81aafdf67cb1cbe3e17d60c550fa0842 Mon Sep 17 00:00:00 2001 From: dctouch Date: Tue, 21 Apr 2026 08:18:09 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BC=D0=B5=D1=81=D1=8F=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20MCP=20discovery=20=D0=B4=D0=BB=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=D1=82=D0=BE-=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...alog_authority_recovery_plan_2026-04-19.md | 43 +++++ ...s_phase19_mcp_discovery_response_gate.json | 36 +++- .../assistantMcpDiscoveryAnswerAdapter.js | 71 +++++++- .../assistantMcpDiscoveryPilotExecutor.js | 121 ++++++++++++- .../services/assistantMcpDiscoveryPlanner.js | 11 +- .../services/assistantMcpDiscoveryPolicy.js | 4 + .../assistantMcpDiscoveryResponseCandidate.js | 6 + .../assistantMcpDiscoveryTurnInputAdapter.js | 12 ++ .../assistantMcpDiscoveryAnswerAdapter.ts | 81 ++++++++- .../assistantMcpDiscoveryPilotExecutor.ts | 169 +++++++++++++++++- .../services/assistantMcpDiscoveryPlanner.ts | 12 +- .../services/assistantMcpDiscoveryPolicy.ts | 5 + .../assistantMcpDiscoveryResponseCandidate.ts | 6 + .../assistantMcpDiscoveryTurnInputAdapter.ts | 15 ++ ...assistantMcpDiscoveryAnswerAdapter.test.ts | 42 +++++ ...assistantMcpDiscoveryPilotExecutor.test.ts | 59 ++++++ .../assistantMcpDiscoveryPlanner.test.ts | 29 +++ ...stantMcpDiscoveryResponseCandidate.test.ts | 31 ++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 20 +++ 19 files changed, 753 insertions(+), 20 deletions(-) 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 735b1d0..a70d14e 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 @@ -1261,6 +1261,49 @@ Module progress: - Big Block 5 MCP Semantic Data Agent: `99%`. +## Progress Update - 2026-04-21 MCP Discovery Monthly Bidirectional Multi-Axis Aggregation + +The nineteenth implementation slice of Big Block 5 closes the first explicit multi-axis aggregation gap for guarded MCP discovery. + +Before this slice the assistant could answer: + +- incoming value-flow total; +- outgoing supplier-payout total; +- composed bidirectional net total. + +But it could not keep a user request like "по месяцам" as part of the machine-readable turn meaning, plan shape, and final guarded answer. + +New behavior: + +- monthly wording such as `по месяцам`, `помесячно`, `ежемесячно`, `monthly`, and `month by month` is captured as `asked_aggregation_axis=month`; +- the discovery planner keeps the monthly request as an explicit `calendar_month` axis on top of the existing guarded value-flow recipe; +- runtime derives `monthly_breakdown` for single-direction value-flow and for composed bidirectional net value-flow from the same confirmed 1C rows; +- the guarded answer draft now adds month-by-month business lines instead of flattening a monthly question back into a single total; +- the response-candidate layer keeps those monthly lines user-facing and localizes the monthly inference basis without leaking planner/runtime/pilot mechanics. + +Replay result: + +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun12` passed `8/8` after adding the new monthly step and exposed one presentation defect: duplicated punctuation in monthly amount lines; +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun13` passed `8/8` after punctuation cleanup; +- final status: `accepted`; +- the new step `step_07_counterparty_bidirectional_monthly_net_flow_uses_guarded_discovery` answered through guarded MCP discovery with monthly lines from `янв 2020` through `дек 2020`; +- user-facing text keeps the same honesty boundary as the total net answer: outgoing coverage still hits the `100`-row probe limit, so the monthly shape is a found-row calculation rather than a fully proven all-period saldo; +- user-facing monthly output contains no `query_documents`, `query_movements`, `runtime_`, `planner_`, `catalog_`, `primitive`, or `pilot_` leak. + +Validation: + +- `npm test -- assistantMcpDiscoveryTurnInputAdapter.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 `90/90`; +- `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_rerun13 --timeout-seconds 180` 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; follow-up drilldown over the discovered rows and broader self-navigation remain future work outside this first completed guarded multi-axis slice. + +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 d85b638..0d8fbb6 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 @@ -173,7 +173,41 @@ ] }, { - "step_id": "step_07_off_domain_living_chat_not_hijacked", + "step_id": "step_07_counterparty_bidirectional_monthly_net_flow_uses_guarded_discovery", + "title": "Unsupported-but-understood counterparty monthly net cash-flow question keeps monthly structure from discovery", + "question": "какое помесячное нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили по месяцам?", + "required_answer_patterns_all": [ + "(?i)свк", + "(?i)1с|найден|строк|проверен", + "(?i)получил|входящ|поступ", + "(?i)заплат|исходящ|списан|плат[её]ж", + "(?i)помесяч|по месяцам|янв|фев|мар|апр|май|июн|июл|авг|сен|окт|ноя|дек", + "(?i)нетто|сальдо|разниц", + "(?i)сумм|руб", + "(?i)2020|период", + "(?i)не подтвержд|проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)точный маршрут.*не подключ", + "(?i)не буду подставлять", + "(?i)query_documents", + "(?i)query_movements", + "(?i)runtime_", + "(?i)planner_", + "(?i)catalog_", + "(?i)primitive", + "(?i)pilot_" + ], + "criticality": "critical", + "semantic_tags": [ + "mcp_discovery_bidirectional_value_flow", + "counterparty_monthly_net_cash_flow", + "multi_axis_aggregation", + "unsupported_current_turn_meaning_boundary" + ] + }, + { + "step_id": "step_08_off_domain_living_chat_not_hijacked", "title": "Off-domain living chat remains human and is not hijacked by discovery carryover", "question": "а чем капибара отличается от утки?", "required_answer_patterns_any": [ diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 3796ea3..9f5d6a9 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -65,6 +65,17 @@ function isValueFlowPilot(pilot) { pilot.pilot_scope === "counterparty_bidirectional_value_flow_query_movements_v1"); } function headlineFor(mode, pilot) { + const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || + pilot.derived_value_flow?.aggregation_axis === "month"; + if (askedMonthlyBreakdown && pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") { + return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто и помесячная раскладка могут называться только как расчет по найденным строкам и проверенному периоду."; + } + if (askedMonthlyBreakdown && pilot.derived_value_flow && mode === "confirmed_with_bounded_inference") { + if (pilot.derived_value_flow.value_flow_direction === "outgoing_supplier_payout") { + return "По данным 1С найдены строки исходящих платежей/списаний; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; + } + return "По данным 1С найдены строки денежных движений; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; + } if (pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") { return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто можно называть только как расчет по найденным строкам и проверенному периоду."; } @@ -118,6 +129,38 @@ function buildMustNotClaim(pilot) { } return claims; } +const RU_MONTH_LABELS_SHORT = [ + "янв", + "фев", + "мар", + "апр", + "май", + "июн", + "июл", + "авг", + "сен", + "окт", + "ноя", + "дек" +]; +function monthLabelRu(monthBucket) { + const match = monthBucket.match(/^(\d{4})-(\d{2})$/); + if (!match) { + return monthBucket; + } + const monthIndex = Number(match[2]) - 1; + const label = RU_MONTH_LABELS_SHORT[monthIndex] ?? match[2]; + return `${label} ${match[1]}`; +} +function netLabelRu(netDirection) { + if (netDirection === "net_incoming") { + return "нетто в нашу сторону"; + } + if (netDirection === "net_outgoing") { + return "нетто исходящее"; + } + return "нетто нулевое"; +} function derivedActivityInferenceLine(pilot) { const period = pilot.derived_activity_period; if (!period) { @@ -151,6 +194,19 @@ function derivedValueFlowConfirmedLine(pilot) { : ""; return `По найденным строкам ${movementLabel} в 1С${counterparty}${period} ${totalLabel} ${flow.total_amount_human_ru} Учтено строк с суммой: ${flow.rows_with_amount} из ${flow.rows_matched}.${dates}${limitCaveat} ${caveat}`; } +function derivedValueFlowMonthlyLines(pilot) { + const flow = pilot.derived_value_flow; + if (!flow || flow.aggregation_axis !== "month" || flow.monthly_breakdown.length === 0) { + return []; + } + return flow.monthly_breakdown.map((bucket) => { + const monthLabel = monthLabelRu(bucket.month_bucket); + if (flow.value_flow_direction === "outgoing_supplier_payout") { + return `Помесячно: ${monthLabel} — заплатили ${bucket.total_amount_human_ru} по ${bucket.rows_with_amount} строкам с суммой`; + } + return `Помесячно: ${monthLabel} — получили ${bucket.total_amount_human_ru} по ${bucket.rows_with_amount} строкам с суммой`; + }); +} function sideDateRange(first, latest) { if (first && latest) { return ` первая дата ${first}, последняя ${latest}`; @@ -185,6 +241,13 @@ function derivedBidirectionalValueFlowConfirmedLine(pilot) { .replace(/\s+/g, " ") .trim(); } +function derivedBidirectionalValueFlowMonthlyLines(pilot) { + const flow = pilot.derived_bidirectional_value_flow; + if (!flow || flow.aggregation_axis !== "month" || flow.monthly_breakdown.length === 0) { + return []; + } + return flow.monthly_breakdown.map((bucket) => `Помесячно: ${monthLabelRu(bucket.month_bucket)} — получили ${bucket.incoming_total_amount_human_ru}, заплатили ${bucket.outgoing_total_amount_human_ru}, ${netLabelRu(bucket.net_direction)} ${bucket.net_amount_human_ru}`); +} function buildAssistantMcpDiscoveryAnswerDraft(pilot) { const mode = modeFor(pilot); const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes]; @@ -200,8 +263,14 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { ? [derivedInferenceLine] : pilot.evidence.inferred_facts; const derivedValueLine = derivedBidirectionalValueFlowConfirmedLine(pilot) ?? derivedValueFlowConfirmedLine(pilot); + const monthlyConfirmedLines = derivedBidirectionalValueFlowMonthlyLines(pilot).length > 0 + ? derivedBidirectionalValueFlowMonthlyLines(pilot) + : derivedValueFlowMonthlyLines(pilot); + if (monthlyConfirmedLines.length > 0) { + pushReason(reasonCodes, "answer_contains_monthly_breakdown"); + } const confirmedLines = derivedValueLine - ? [...pilot.evidence.confirmed_facts, derivedValueLine] + ? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines] : pilot.evidence.confirmed_facts; return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 4a0527d..25f9d62 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -37,6 +37,10 @@ function pushUnique(target, value) { target.push(text); } } +function aggregationAxisForPlanner(planner) { + const axis = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_aggregation_axis)?.toLowerCase(); + return axis === "month" ? "month" : null; +} function firstEntityCandidate(planner) { const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; for (const candidate of candidates) { @@ -231,6 +235,19 @@ function rowAmountValue(row) { } return null; } +function monthBucketFromIsoDate(isoDate) { + const match = isoDate?.match(/^(\d{4})-(\d{2})-\d{2}$/); + return match ? `${match[1]}-${match[2]}` : null; +} +function netDirectionFromAmount(amount) { + if (amount > 0) { + return "net_incoming"; + } + if (amount < 0) { + return "net_outgoing"; + } + return "balanced"; +} function monthDiff(firstIsoDate, latestIsoDate) { const first = new Date(`${firstIsoDate}T00:00:00.000Z`); const latest = new Date(`${latestIsoDate}T00:00:00.000Z`); @@ -290,7 +307,70 @@ function formatAmountHumanRu(amount) { .replace(/\u00a0/g, " "); return `${formatted} руб.`; } -function deriveValueFlow(result, counterparty, periodScope, direction, probeLimit) { +function deriveValueFlowMonthBreakdown(result, aggregationAxis) { + if (!result || result.error || aggregationAxis !== "month") { + return []; + } + const buckets = new Map(); + for (const row of result.rows) { + const isoDate = rowDateValue(row); + const monthBucket = monthBucketFromIsoDate(isoDate); + const amount = rowAmountValue(row); + if (!monthBucket || amount === null) { + continue; + } + const current = buckets.get(monthBucket) ?? { rows_with_amount: 0, total_amount: 0 }; + current.rows_with_amount += 1; + current.total_amount += amount; + buckets.set(monthBucket, current); + } + return Array.from(buckets.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([monthBucket, bucket]) => ({ + month_bucket: monthBucket, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + })); +} +function deriveBidirectionalValueFlowMonthBreakdown(input) { + if (input.aggregationAxis !== "month") { + return []; + } + const incomingBuckets = deriveValueFlowMonthBreakdown(input.incomingResult, "month"); + const outgoingBuckets = deriveValueFlowMonthBreakdown(input.outgoingResult, "month"); + const allMonthBuckets = new Set(); + for (const bucket of incomingBuckets) { + allMonthBuckets.add(bucket.month_bucket); + } + for (const bucket of outgoingBuckets) { + allMonthBuckets.add(bucket.month_bucket); + } + const incomingByMonth = new Map(incomingBuckets.map((bucket) => [bucket.month_bucket, bucket])); + const outgoingByMonth = new Map(outgoingBuckets.map((bucket) => [bucket.month_bucket, bucket])); + return Array.from(allMonthBuckets) + .sort((left, right) => left.localeCompare(right)) + .map((monthBucket) => { + const incoming = incomingByMonth.get(monthBucket); + const outgoing = outgoingByMonth.get(monthBucket); + const incomingAmount = incoming?.total_amount ?? 0; + const outgoingAmount = outgoing?.total_amount ?? 0; + const netAmount = incomingAmount - outgoingAmount; + return { + month_bucket: monthBucket, + incoming_total_amount: incomingAmount, + incoming_total_amount_human_ru: formatAmountHumanRu(incomingAmount), + incoming_rows_with_amount: incoming?.rows_with_amount ?? 0, + outgoing_total_amount: outgoingAmount, + outgoing_total_amount_human_ru: formatAmountHumanRu(outgoingAmount), + outgoing_rows_with_amount: outgoing?.rows_with_amount ?? 0, + net_amount: netAmount, + net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)), + net_direction: netDirectionFromAmount(netAmount) + }; + }); +} +function deriveValueFlow(result, counterparty, periodScope, direction, probeLimit, aggregationAxis) { if (!result || result.error || result.matched_rows <= 0) { return null; } @@ -314,6 +394,7 @@ function deriveValueFlow(result, counterparty, periodScope, direction, probeLimi value_flow_direction: direction, counterparty, period_scope: periodScope, + aggregation_axis: aggregationAxis, rows_matched: result.matched_rows, rows_with_amount: rowsWithAmount, total_amount: totalAmount, @@ -321,6 +402,7 @@ function deriveValueFlow(result, counterparty, periodScope, direction, probeLimi first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, coverage_limited_by_probe_limit: result.matched_rows >= probeLimit, + monthly_breakdown: deriveValueFlowMonthBreakdown(result, aggregationAxis), inference_basis: "sum_of_confirmed_1c_value_flow_rows" }; } @@ -369,12 +451,18 @@ function deriveBidirectionalValueFlow(input) { return { counterparty: input.counterparty, period_scope: input.periodScope, + aggregation_axis: input.aggregationAxis, incoming_customer_revenue: incoming, outgoing_supplier_payout: outgoing, net_amount: netAmount, net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)), - net_direction: netAmount > 0 ? "net_incoming" : netAmount < 0 ? "net_outgoing" : "balanced", + net_direction: netDirectionFromAmount(netAmount), coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, + monthly_breakdown: deriveBidirectionalValueFlowMonthBreakdown({ + incomingResult: input.incomingResult, + outgoingResult: input.outgoingResult, + aggregationAxis: input.aggregationAxis + }), inference_basis: "incoming_minus_outgoing_confirmed_1c_value_flow_rows" }; } @@ -444,16 +532,27 @@ function buildValueFlowInferredFacts(derived) { if (!derived) { return []; } + const facts = []; if (derived.value_flow_direction === "outgoing_supplier_payout") { - return ["Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"]; + facts.push("Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"); } - return ["Counterparty value-flow total was calculated from confirmed 1C movement rows"]; + else { + facts.push("Counterparty value-flow total was calculated from confirmed 1C movement rows"); + } + 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"); + } + return facts; } function buildBidirectionalValueFlowInferredFacts(derived) { if (!derived) { return []; } - return ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"]; + const facts = ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"]; + 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"); + } + return facts; } function buildLifecycleUnknownFacts() { return ["Legal registration date is not proven by this MCP discovery pilot"]; @@ -574,6 +673,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { } const counterparty = firstEntityCandidate(planner); const dateScope = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_date_scope); + const aggregationAxis = aggregationAxisForPlanner(planner); if (valueFlowPilotEligible) { let queryResult = null; const filters = buildValueFlowFilters(planner); @@ -645,10 +745,14 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { outgoingResult, counterparty, periodScope: dateScope, - probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe + probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe, + aggregationAxis }); if (derivedBidirectionalValueFlow) { pushReason(reasonCodes, "pilot_derived_bidirectional_value_flow_from_confirmed_rows"); + if (aggregationAxis === "month" && derivedBidirectionalValueFlow.monthly_breakdown.length > 0) { + pushReason(reasonCodes, "pilot_derived_bidirectional_monthly_breakdown_from_confirmed_rows"); + } } const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, @@ -729,9 +833,12 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { } } const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null; - const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope, valueFlowProfile.direction, planner.discovery_plan.execution_budget.max_rows_per_probe); + const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope, valueFlowProfile.direction, planner.discovery_plan.execution_budget.max_rows_per_probe, aggregationAxis); if (derivedValueFlow) { pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows"); + if (aggregationAxis === "month" && derivedValueFlow.monthly_breakdown.length > 0) { + pushReason(reasonCodes, "pilot_derived_value_flow_monthly_breakdown_from_confirmed_rows"); + } } const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index 7356221..f163863 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -38,6 +38,9 @@ function pushUnique(target, value) { function hasEntity(meaning) { return (meaning?.explicit_entity_candidates?.length ?? 0) > 0; } +function aggregationAxis(meaning) { + return toNonEmptyString(meaning?.asked_aggregation_axis)?.toLowerCase() ?? null; +} function addScopeAxes(axes, meaning) { if (hasEntity(meaning)) { pushUnique(axes, "counterparty"); @@ -59,16 +62,22 @@ function recipeFor(input) { const unsupported = lower(meaning?.unsupported_but_understood_family); const combined = `${domain} ${action} ${unsupported}`.trim(); const axes = []; + const requestedAggregationAxis = aggregationAxis(meaning); addScopeAxes(axes, meaning); if (includesAny(combined, ["turnover", "revenue", "payment", "payout", "value", "net", "netting", "balance", "cashflow"])) { pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); + if (requestedAggregationAxis === "month") { + pushUnique(axes, "calendar_month"); + } return { semanticDataNeed: "counterparty value-flow evidence", primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], axes, - reason: "planner_selected_value_flow_recipe" + reason: requestedAggregationAxis === "month" + ? "planner_selected_monthly_value_flow_recipe" + : "planner_selected_value_flow_recipe" }; } if (includesAny(combined, ["document", "documents"])) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js index be9cae1..9b9c98f 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -74,6 +74,7 @@ function normalizeTurnMeaning(value) { const result = {}; const domain = toNonEmptyString(value.asked_domain_family); const action = toNonEmptyString(value.asked_action_family); + const aggregationAxis = toNonEmptyString(value.asked_aggregation_axis); const organization = toNonEmptyString(value.explicit_organization_scope); const dateScope = toNonEmptyString(value.explicit_date_scope); const unsupported = toNonEmptyString(value.unsupported_but_understood_family); @@ -84,6 +85,9 @@ function normalizeTurnMeaning(value) { if (action) { result.asked_action_family = action; } + if (aggregationAxis) { + result.asked_aggregation_axis = aggregationAxis; + } if (entities.length > 0) { result.explicit_entity_candidates = entities; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 1a0179e..5a7f568 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -100,12 +100,18 @@ function localizeLine(value) { if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) { return "Сумма рассчитана только по подтвержденным строкам денежных движений в 1С."; } + if (/^Counterparty monthly value-flow breakdown was grouped by month over confirmed 1C movement rows$/i.test(value)) { + return "Помесячная раскладка денежного потока сгруппирована только по подтвержденным строкам движений 1С."; + } if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) { return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С."; } if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) { return "Нетто денежного потока рассчитано только как входящие подтвержденные строки 1С минус исходящие подтвержденные строки 1С."; } + if (/^Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows$/i.test(value)) { + return "Помесячная нетто-раскладка сгруппирована только по подтвержденным входящим и исходящим строкам 1С."; + } if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { return "Юридическая дата регистрации этим поиском не подтверждена."; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 78dbfd0..bf9d4f5 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -104,6 +104,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 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); +} function semanticNeedFor(input) { const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); if (input.lifecycleSignal || /(?:lifecycle|activity|duration|age)/iu.test(combined)) { @@ -142,8 +145,10 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const bidirectionalValueFlowSignal = !lifecycleSignal && hasBidirectionalValueFlowSignal(rawText); const valueFlowSignal = !lifecycleSignal && (hasValueFlowSignal(rawText) || bidirectionalValueFlowSignal); const payoutSignal = valueFlowSignal && !bidirectionalValueFlowSignal && hasPayoutSignal(rawText); + const monthlyAggregationSignal = valueFlowSignal && hasMonthlyAggregationSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); + const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const semanticDataNeed = semanticNeedFor({ @@ -170,6 +175,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "payout" : "turnover" : rawAction, + asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: collectDateScope(predecomposeContract), @@ -192,6 +198,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (toNonEmptyString(turnMeaning.asked_action_family)) { cleanTurnMeaning.asked_action_family = turnMeaning.asked_action_family; } + if (toNonEmptyString(turnMeaning.asked_aggregation_axis)) { + cleanTurnMeaning.asked_aggregation_axis = turnMeaning.asked_aggregation_axis; + } if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } @@ -236,6 +245,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (bidirectionalValueFlowSignal) { pushReason(reasonCodes, "mcp_discovery_bidirectional_value_flow_signal_detected"); } + if (monthlyAggregationSignal) { + pushReason(reasonCodes, "mcp_discovery_monthly_aggregation_signal_detected"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 6f3edd8..b914db7 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -98,6 +98,18 @@ function isValueFlowPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): b } function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string { + const askedMonthlyBreakdown = + pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || + pilot.derived_value_flow?.aggregation_axis === "month"; + if (askedMonthlyBreakdown && pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") { + return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто и помесячная раскладка могут называться только как расчет по найденным строкам и проверенному периоду."; + } + if (askedMonthlyBreakdown && pilot.derived_value_flow && mode === "confirmed_with_bounded_inference") { + if (pilot.derived_value_flow.value_flow_direction === "outgoing_supplier_payout") { + return "По данным 1С найдены строки исходящих платежей/списаний; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; + } + return "По данным 1С найдены строки денежных движений; сумму и помесячную раскладку можно называть только в рамках проверенного периода и найденных строк."; + } if (pilot.derived_bidirectional_value_flow && mode === "confirmed_with_bounded_inference") { return "По данным 1С найдены строки входящих и исходящих денежных движений; нетто можно называть только как расчет по найденным строкам и проверенному периоду."; } @@ -154,6 +166,41 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): return claims; } +const RU_MONTH_LABELS_SHORT = [ + "янв", + "фев", + "мар", + "апр", + "май", + "июн", + "июл", + "авг", + "сен", + "окт", + "ноя", + "дек" +] as const; + +function monthLabelRu(monthBucket: string): string { + const match = monthBucket.match(/^(\d{4})-(\d{2})$/); + if (!match) { + return monthBucket; + } + const monthIndex = Number(match[2]) - 1; + const label = RU_MONTH_LABELS_SHORT[monthIndex] ?? match[2]; + return `${label} ${match[1]}`; +} + +function netLabelRu(netDirection: "net_incoming" | "net_outgoing" | "balanced"): string { + if (netDirection === "net_incoming") { + return "нетто в нашу сторону"; + } + if (netDirection === "net_outgoing") { + return "нетто исходящее"; + } + return "нетто нулевое"; +} + function derivedActivityInferenceLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { const period = pilot.derived_activity_period; if (!period) { @@ -193,6 +240,20 @@ function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutio return `По найденным строкам ${movementLabel} в 1С${counterparty}${period} ${totalLabel} ${flow.total_amount_human_ru} Учтено строк с суммой: ${flow.rows_with_amount} из ${flow.rows_matched}.${dates}${limitCaveat} ${caveat}`; } +function derivedValueFlowMonthlyLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] { + const flow = pilot.derived_value_flow; + if (!flow || flow.aggregation_axis !== "month" || flow.monthly_breakdown.length === 0) { + return []; + } + return flow.monthly_breakdown.map((bucket) => { + const monthLabel = monthLabelRu(bucket.month_bucket); + if (flow.value_flow_direction === "outgoing_supplier_payout") { + return `Помесячно: ${monthLabel} — заплатили ${bucket.total_amount_human_ru} по ${bucket.rows_with_amount} строкам с суммой`; + } + return `Помесячно: ${monthLabel} — получили ${bucket.total_amount_human_ru} по ${bucket.rows_with_amount} строкам с суммой`; + }); +} + function sideDateRange(first: string | null, latest: string | null): string { if (first && latest) { return ` первая дата ${first}, последняя ${latest}`; @@ -230,6 +291,17 @@ function derivedBidirectionalValueFlowConfirmedLine(pilot: AssistantMcpDiscovery .trim(); } +function derivedBidirectionalValueFlowMonthlyLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] { + const flow = pilot.derived_bidirectional_value_flow; + if (!flow || flow.aggregation_axis !== "month" || flow.monthly_breakdown.length === 0) { + return []; + } + return flow.monthly_breakdown.map( + (bucket) => + `Помесячно: ${monthLabelRu(bucket.month_bucket)} — получили ${bucket.incoming_total_amount_human_ru}, заплатили ${bucket.outgoing_total_amount_human_ru}, ${netLabelRu(bucket.net_direction)} ${bucket.net_amount_human_ru}` + ); +} + export function buildAssistantMcpDiscoveryAnswerDraft( pilot: AssistantMcpDiscoveryPilotExecutionContract ): AssistantMcpDiscoveryAnswerDraftContract { @@ -247,8 +319,15 @@ export function buildAssistantMcpDiscoveryAnswerDraft( ? [derivedInferenceLine] : pilot.evidence.inferred_facts; const derivedValueLine = derivedBidirectionalValueFlowConfirmedLine(pilot) ?? derivedValueFlowConfirmedLine(pilot); + const monthlyConfirmedLines = + derivedBidirectionalValueFlowMonthlyLines(pilot).length > 0 + ? derivedBidirectionalValueFlowMonthlyLines(pilot) + : derivedValueFlowMonthlyLines(pilot); + if (monthlyConfirmedLines.length > 0) { + pushReason(reasonCodes, "answer_contains_monthly_breakdown"); + } const confirmedLines = derivedValueLine - ? [...pilot.evidence.confirmed_facts, derivedValueLine] + ? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines] : pilot.evidence.confirmed_facts; return { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index 588704d..f01643a 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -40,10 +40,21 @@ export interface AssistantMcpDiscoveryDerivedActivityPeriod { inference_basis: "first_and_latest_confirmed_1c_activity_rows"; } +export type AssistantMcpDiscoveryAggregationAxis = "month"; +export type AssistantMcpDiscoveryNetDirection = "net_incoming" | "net_outgoing" | "balanced"; + +export interface AssistantMcpDiscoveryValueFlowMonthBucket { + month_bucket: string; + rows_with_amount: number; + total_amount: number; + total_amount_human_ru: string; +} + export interface AssistantMcpDiscoveryDerivedValueFlow { value_flow_direction: "incoming_customer_revenue" | "outgoing_supplier_payout"; counterparty: string | null; period_scope: string | null; + aggregation_axis: AssistantMcpDiscoveryAggregationAxis | null; rows_matched: number; rows_with_amount: number; total_amount: number; @@ -51,6 +62,7 @@ export interface AssistantMcpDiscoveryDerivedValueFlow { first_movement_date: string | null; latest_movement_date: string | null; coverage_limited_by_probe_limit: boolean; + monthly_breakdown: AssistantMcpDiscoveryValueFlowMonthBucket[]; inference_basis: "sum_of_confirmed_1c_value_flow_rows"; } @@ -64,15 +76,30 @@ export interface AssistantMcpDiscoveryValueFlowSideSummary { coverage_limited_by_probe_limit: boolean; } +export interface AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket { + month_bucket: string; + incoming_total_amount: number; + incoming_total_amount_human_ru: string; + incoming_rows_with_amount: number; + outgoing_total_amount: number; + outgoing_total_amount_human_ru: string; + outgoing_rows_with_amount: number; + net_amount: number; + net_amount_human_ru: string; + net_direction: AssistantMcpDiscoveryNetDirection; +} + export interface AssistantMcpDiscoveryDerivedBidirectionalValueFlow { counterparty: string | null; period_scope: string | null; + aggregation_axis: AssistantMcpDiscoveryAggregationAxis | null; incoming_customer_revenue: AssistantMcpDiscoveryValueFlowSideSummary; outgoing_supplier_payout: AssistantMcpDiscoveryValueFlowSideSummary; net_amount: number; net_amount_human_ru: string; - net_direction: "net_incoming" | "net_outgoing" | "balanced"; + net_direction: AssistantMcpDiscoveryNetDirection; coverage_limited_by_probe_limit: boolean; + monthly_breakdown: AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket[]; inference_basis: "incoming_minus_outgoing_confirmed_1c_value_flow_rows"; } @@ -138,6 +165,13 @@ function pushUnique(target: string[], value: string): void { } } +function aggregationAxisForPlanner( + planner: AssistantMcpDiscoveryPlannerContract +): AssistantMcpDiscoveryAggregationAxis | null { + const axis = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_aggregation_axis)?.toLowerCase(); + return axis === "month" ? "month" : null; +} + function firstEntityCandidate(planner: AssistantMcpDiscoveryPlannerContract): string | null { const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; for (const candidate of candidates) { @@ -367,6 +401,21 @@ function rowAmountValue(row: Record): number | null { 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; +} + +function netDirectionFromAmount(amount: number): AssistantMcpDiscoveryNetDirection { + if (amount > 0) { + return "net_incoming"; + } + if (amount < 0) { + return "net_outgoing"; + } + return "balanced"; +} + function monthDiff(firstIsoDate: string, latestIsoDate: string): number { const first = new Date(`${firstIsoDate}T00:00:00.000Z`); const latest = new Date(`${latestIsoDate}T00:00:00.000Z`); @@ -432,12 +481,88 @@ function formatAmountHumanRu(amount: number): string { return `${formatted} руб.`; } +function deriveValueFlowMonthBreakdown( + result: AddressMcpQueryExecutorResult | null, + aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null +): AssistantMcpDiscoveryValueFlowMonthBucket[] { + if (!result || result.error || aggregationAxis !== "month") { + return []; + } + const buckets = new Map(); + for (const row of result.rows) { + const isoDate = rowDateValue(row); + const monthBucket = monthBucketFromIsoDate(isoDate); + const amount = rowAmountValue(row); + if (!monthBucket || amount === null) { + continue; + } + const current = buckets.get(monthBucket) ?? { rows_with_amount: 0, total_amount: 0 }; + current.rows_with_amount += 1; + current.total_amount += amount; + buckets.set(monthBucket, current); + } + return Array.from(buckets.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([monthBucket, bucket]) => ({ + month_bucket: monthBucket, + rows_with_amount: bucket.rows_with_amount, + total_amount: bucket.total_amount, + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + })); +} + +function deriveBidirectionalValueFlowMonthBreakdown(input: { + incomingResult: AddressMcpQueryExecutorResult | null; + outgoingResult: AddressMcpQueryExecutorResult | null; + aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null; +}): AssistantMcpDiscoveryBidirectionalValueFlowMonthBucket[] { + if (input.aggregationAxis !== "month") { + return []; + } + + const incomingBuckets = deriveValueFlowMonthBreakdown(input.incomingResult, "month"); + const outgoingBuckets = deriveValueFlowMonthBreakdown(input.outgoingResult, "month"); + const allMonthBuckets = new Set(); + for (const bucket of incomingBuckets) { + allMonthBuckets.add(bucket.month_bucket); + } + for (const bucket of outgoingBuckets) { + allMonthBuckets.add(bucket.month_bucket); + } + + const incomingByMonth = new Map(incomingBuckets.map((bucket) => [bucket.month_bucket, bucket])); + const outgoingByMonth = new Map(outgoingBuckets.map((bucket) => [bucket.month_bucket, bucket])); + + return Array.from(allMonthBuckets) + .sort((left, right) => left.localeCompare(right)) + .map((monthBucket) => { + const incoming = incomingByMonth.get(monthBucket); + const outgoing = outgoingByMonth.get(monthBucket); + const incomingAmount = incoming?.total_amount ?? 0; + const outgoingAmount = outgoing?.total_amount ?? 0; + const netAmount = incomingAmount - outgoingAmount; + return { + month_bucket: monthBucket, + incoming_total_amount: incomingAmount, + incoming_total_amount_human_ru: formatAmountHumanRu(incomingAmount), + incoming_rows_with_amount: incoming?.rows_with_amount ?? 0, + outgoing_total_amount: outgoingAmount, + outgoing_total_amount_human_ru: formatAmountHumanRu(outgoingAmount), + outgoing_rows_with_amount: outgoing?.rows_with_amount ?? 0, + net_amount: netAmount, + net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)), + net_direction: netDirectionFromAmount(netAmount) + }; + }); +} + function deriveValueFlow( result: AddressMcpQueryExecutorResult | null, counterparty: string | null, periodScope: string | null, direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"], - probeLimit: number + probeLimit: number, + aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null ): AssistantMcpDiscoveryDerivedValueFlow | null { if (!result || result.error || result.matched_rows <= 0) { return null; @@ -462,6 +587,7 @@ function deriveValueFlow( value_flow_direction: direction, counterparty, period_scope: periodScope, + aggregation_axis: aggregationAxis, rows_matched: result.matched_rows, rows_with_amount: rowsWithAmount, total_amount: totalAmount, @@ -469,6 +595,7 @@ function deriveValueFlow( first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, coverage_limited_by_probe_limit: result.matched_rows >= probeLimit, + monthly_breakdown: deriveValueFlowMonthBreakdown(result, aggregationAxis), inference_basis: "sum_of_confirmed_1c_value_flow_rows" }; } @@ -519,6 +646,7 @@ function deriveBidirectionalValueFlow(input: { 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); @@ -529,13 +657,19 @@ function deriveBidirectionalValueFlow(input: { return { counterparty: input.counterparty, period_scope: input.periodScope, + aggregation_axis: input.aggregationAxis, incoming_customer_revenue: incoming, outgoing_supplier_payout: outgoing, net_amount: netAmount, net_amount_human_ru: formatAmountHumanRu(Math.abs(netAmount)), - net_direction: netAmount > 0 ? "net_incoming" : netAmount < 0 ? "net_outgoing" : "balanced", + net_direction: netDirectionFromAmount(netAmount), coverage_limited_by_probe_limit: incoming.coverage_limited_by_probe_limit || outgoing.coverage_limited_by_probe_limit, + monthly_breakdown: deriveBidirectionalValueFlowMonthBreakdown({ + incomingResult: input.incomingResult, + outgoingResult: input.outgoingResult, + aggregationAxis: input.aggregationAxis + }), inference_basis: "incoming_minus_outgoing_confirmed_1c_value_flow_rows" }; } @@ -620,10 +754,16 @@ function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueF if (!derived) { return []; } + const facts: string[] = []; if (derived.value_flow_direction === "outgoing_supplier_payout") { - return ["Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"]; + facts.push("Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"); + } else { + facts.push("Counterparty value-flow total was calculated from confirmed 1C movement rows"); } - return ["Counterparty value-flow total was calculated from confirmed 1C movement rows"]; + 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"); + } + return facts; } function buildBidirectionalValueFlowInferredFacts( @@ -632,7 +772,11 @@ function buildBidirectionalValueFlowInferredFacts( if (!derived) { return []; } - return ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"]; + const facts = ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"]; + 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"); + } + return facts; } function buildLifecycleUnknownFacts(): string[] { @@ -786,6 +930,7 @@ export async function executeAssistantMcpDiscoveryPilot( const counterparty = firstEntityCandidate(planner); const dateScope = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_date_scope); + const aggregationAxis = aggregationAxisForPlanner(planner); if (valueFlowPilotEligible) { let queryResult: AddressMcpQueryExecutorResult | null = null; @@ -864,10 +1009,14 @@ export async function executeAssistantMcpDiscoveryPilot( outgoingResult, counterparty, periodScope: dateScope, - probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe + probeLimit: planner.discovery_plan.execution_budget.max_rows_per_probe, + aggregationAxis }); if (derivedBidirectionalValueFlow) { pushReason(reasonCodes, "pilot_derived_bidirectional_value_flow_from_confirmed_rows"); + if (aggregationAxis === "month" && derivedBidirectionalValueFlow.monthly_breakdown.length > 0) { + pushReason(reasonCodes, "pilot_derived_bidirectional_monthly_breakdown_from_confirmed_rows"); + } } const evidence = resolveAssistantMcpDiscoveryEvidence({ plan: planner.discovery_plan, @@ -959,10 +1108,14 @@ export async function executeAssistantMcpDiscoveryPilot( counterparty, dateScope, valueFlowProfile.direction, - planner.discovery_plan.execution_budget.max_rows_per_probe + planner.discovery_plan.execution_budget.max_rows_per_probe, + aggregationAxis ); if (derivedValueFlow) { pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows"); + if (aggregationAxis === "month" && derivedValueFlow.monthly_breakdown.length > 0) { + pushReason(reasonCodes, "pilot_derived_value_flow_monthly_breakdown_from_confirmed_rows"); + } } const evidence = resolveAssistantMcpDiscoveryEvidence({ plan: planner.discovery_plan, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index c5aa6e2..0a70f7d 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -76,6 +76,10 @@ function hasEntity(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefin return (meaning?.explicit_entity_candidates?.length ?? 0) > 0; } +function aggregationAxis(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): string | null { + return toNonEmptyString(meaning?.asked_aggregation_axis)?.toLowerCase() ?? null; +} + function addScopeAxes(axes: string[], meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): void { if (hasEntity(meaning)) { pushUnique(axes, "counterparty"); @@ -99,17 +103,23 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const unsupported = lower(meaning?.unsupported_but_understood_family); const combined = `${domain} ${action} ${unsupported}`.trim(); const axes: string[] = []; + const requestedAggregationAxis = aggregationAxis(meaning); addScopeAxes(axes, meaning); if (includesAny(combined, ["turnover", "revenue", "payment", "payout", "value", "net", "netting", "balance", "cashflow"])) { pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); + if (requestedAggregationAxis === "month") { + pushUnique(axes, "calendar_month"); + } return { semanticDataNeed: "counterparty value-flow evidence", primitives: ["resolve_entity_reference", "query_movements", "aggregate_by_axis", "probe_coverage"], axes, - reason: "planner_selected_value_flow_recipe" + reason: requestedAggregationAxis === "month" + ? "planner_selected_monthly_value_flow_recipe" + : "planner_selected_value_flow_recipe" }; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index c0325b4..3b3a1c7 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -23,6 +23,7 @@ export type AssistantMcpDiscoveryAnswerPermission = "confirmed_answer" | "bounde export interface AssistantMcpDiscoveryTurnMeaningRef { asked_domain_family?: string | null; asked_action_family?: string | null; + asked_aggregation_axis?: string | null; explicit_entity_candidates?: string[]; explicit_organization_scope?: string | null; explicit_date_scope?: string | null; @@ -164,6 +165,7 @@ function normalizeTurnMeaning( const result: AssistantMcpDiscoveryTurnMeaningRef = {}; const domain = toNonEmptyString(value.asked_domain_family); const action = toNonEmptyString(value.asked_action_family); + const aggregationAxis = toNonEmptyString(value.asked_aggregation_axis); const organization = toNonEmptyString(value.explicit_organization_scope); const dateScope = toNonEmptyString(value.explicit_date_scope); const unsupported = toNonEmptyString(value.unsupported_but_understood_family); @@ -174,6 +176,9 @@ function normalizeTurnMeaning( if (action) { result.asked_action_family = action; } + if (aggregationAxis) { + result.asked_aggregation_axis = aggregationAxis; + } if (entities.length > 0) { result.explicit_entity_candidates = entities; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 30357a7..9718ddb 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -134,12 +134,18 @@ function localizeLine(value: string): string { if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) { return "Сумма рассчитана только по подтвержденным строкам денежных движений в 1С."; } + if (/^Counterparty monthly value-flow breakdown was grouped by month over confirmed 1C movement rows$/i.test(value)) { + return "Помесячная раскладка денежного потока сгруппирована только по подтвержденным строкам движений 1С."; + } if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) { return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С."; } if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) { return "Нетто денежного потока рассчитано только как входящие подтвержденные строки 1С минус исходящие подтвержденные строки 1С."; } + if (/^Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows$/i.test(value)) { + return "Помесячная нетто-раскладка сгруппирована только по подтвержденным входящим и исходящим строкам 1С."; + } if (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { return "Юридическая дата регистрации этим поиском не подтверждена."; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 23793c1..c1d9c00 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -156,6 +156,12 @@ function hasBidirectionalValueFlowSignal(text: string): boolean { ); } +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 + ); +} + function semanticNeedFor(input: { domain: string | null; action: string | null; @@ -210,9 +216,11 @@ export function buildAssistantMcpDiscoveryTurnInput( const bidirectionalValueFlowSignal = !lifecycleSignal && hasBidirectionalValueFlowSignal(rawText); const valueFlowSignal = !lifecycleSignal && (hasValueFlowSignal(rawText) || bidirectionalValueFlowSignal); const payoutSignal = valueFlowSignal && !bidirectionalValueFlowSignal && hasPayoutSignal(rawText); + const monthlyAggregationSignal = valueFlowSignal && hasMonthlyAggregationSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); + const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const semanticDataNeed = semanticNeedFor({ @@ -241,6 +249,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "payout" : "turnover" : rawAction, + asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: collectDateScope(predecomposeContract), @@ -265,6 +274,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (toNonEmptyString(turnMeaning.asked_action_family)) { cleanTurnMeaning.asked_action_family = turnMeaning.asked_action_family; } + if (toNonEmptyString(turnMeaning.asked_aggregation_axis)) { + cleanTurnMeaning.asked_aggregation_axis = turnMeaning.asked_aggregation_axis; + } if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } @@ -311,6 +323,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (bidirectionalValueFlowSignal) { pushReason(reasonCodes, "mcp_discovery_bidirectional_value_flow_signal_detected"); } + if (monthlyAggregationSignal) { + pushReason(reasonCodes, "mcp_discovery_monthly_aggregation_signal_detected"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index cf4a380..01d9c35 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -191,6 +191,48 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.must_not_claim).toContain("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows."); }); + it("renders monthly bidirectional breakdown lines when the turn explicitly asked by month", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + asked_aggregation_axis: "month", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 10000, Counterparty: "SVK" }, + { Period: "2020-02-20T00:00:00", Amount: "2 500,50", Counterparty: "SVK" } + ] + }, + { + rows: [ + { Period: "2020-01-10T00:00:00", Amount: 4000, Counterparty: "SVK" }, + { Period: "2020-02-11T00:00:00", Amount: 1000, Counterparty: "SVK" } + ] + } + ]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + const confirmedText = draft.confirmed_lines.join("\n"); + + expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); + expect(draft.headline).toContain("помесяч"); + expect(confirmedText).toContain("Помесячно: янв 2020"); + expect(confirmedText).toContain("получили 10 000 руб."); + expect(confirmedText).toContain("заплатили 4 000 руб."); + expect(confirmedText).toContain("Помесячно: фев 2020"); + expect(confirmedText).toContain("нетто в нашу сторону 1 500,50 руб."); + expect(draft.reason_codes).toContain("answer_contains_monthly_breakdown"); + }); + 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 a4db30d..06aa061 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -260,6 +260,65 @@ describe("assistant MCP discovery pilot executor", () => { expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2); }); + it("derives monthly bidirectional value-flow breakdown when the turn explicitly asks by month", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + asked_aggregation_axis: "month", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" + } + }); + const deps = buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 10000, Counterparty: "SVK" }, + { Period: "2020-02-20T00:00:00", Amount: "2 500,50", Counterparty: "SVK" } + ] + }, + { + rows: [ + { Period: "2020-01-10T00:00:00", Amount: 4000, Counterparty: "SVK" }, + { Period: "2020-02-11T00:00:00", Amount: 1000, Counterparty: "SVK" } + ] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.derived_bidirectional_value_flow?.aggregation_axis).toBe("month"); + expect(result.derived_bidirectional_value_flow?.monthly_breakdown).toMatchObject([ + { + month_bucket: "2020-01", + incoming_total_amount: 10000, + incoming_rows_with_amount: 1, + outgoing_total_amount: 4000, + outgoing_rows_with_amount: 1, + net_amount: 6000, + net_direction: "net_incoming" + }, + { + month_bucket: "2020-02", + incoming_total_amount: 2500.5, + incoming_rows_with_amount: 1, + outgoing_total_amount: 1000, + outgoing_rows_with_amount: 1, + net_amount: 1500.5, + net_direction: "net_incoming" + } + ]); + expect(result.derived_bidirectional_value_flow?.monthly_breakdown[0]?.incoming_total_amount_human_ru).toContain("10 000"); + expect(result.derived_bidirectional_value_flow?.monthly_breakdown[0]?.net_amount_human_ru).toContain("6 000"); + expect(result.derived_bidirectional_value_flow?.monthly_breakdown[1]?.incoming_total_amount_human_ru).toContain("2 500,50"); + expect(result.derived_bidirectional_value_flow?.monthly_breakdown[1]?.net_amount_human_ru).toContain("1 500,50"); + expect(result.evidence.inferred_facts).toContain( + "Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows" + ); + expect(result.reason_codes).toContain("pilot_derived_bidirectional_monthly_breakdown_from_confirmed_rows"); + }); + 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 36661ba..8f40b06 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -40,6 +40,35 @@ describe("assistant MCP discovery planner", () => { expect(result.reason_codes).toContain("planner_needs_more_user_or_scope_context"); }); + it("keeps requested monthly aggregation as an explicit planning axis for value-flow discovery", () => { + const result = planAssistantMcpDiscovery({ + 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.planner_status).toBe("ready_for_execution"); + 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_recipe"); + }); + it("builds a document discovery plan without falling back to movement primitives", () => { const result = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 9ba3d74..516c508 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -151,6 +151,37 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("query_movements"); }); + it("keeps monthly breakdown lines user-facing and localizes monthly inference basis", () => { + 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 bidirectional value-flow rows were checked for counterparty SVK: incoming=found, outgoing=found", + "Помесячно: янв 2020 — получили 10 000 руб., заплатили 4 000 руб., нетто в нашу сторону 6 000 руб." + ], + inference_lines: [ + "Counterparty monthly net value-flow breakdown was grouped by month over confirmed incoming and outgoing 1C rows" + ], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("Помесячно: янв 2020"); + expect(candidate.reply_text).toContain("Помесячная нетто-раскладка сгруппирована только по подтвержденным входящим и исходящим строкам 1С."); + expect(candidate.reply_text).not.toContain("Counterparty monthly net value-flow breakdown"); + }); + 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", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index bb43682..2e1cefa 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -131,6 +131,26 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected"); }); + it("captures monthly aggregation as part of bidirectional value-flow meaning", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "какое нетто по деньгам с Группа СВК за 2020 год по месяцам: сколько получили и сколько заплатили помесячно?", + predecomposeContract: { + entities: { counterparty: "Группа СВК" }, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + asked_aggregation_axis: "month", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020" + }); + expect(result.reason_codes).toContain("mcp_discovery_monthly_aggregation_signal_detected"); + }); + it("does not activate discovery for supported exact current-turn intent", () => { const result = buildAssistantMcpDiscoveryTurnInput({ assistantTurnMeaning: {