ARCH: добавить помесячный MCP discovery для нетто-потока

This commit is contained in:
dctouch 2026-04-21 08:18:09 +03:00
parent 99a568241d
commit 4baa54fe81
19 changed files with 753 additions and 20 deletions

View File

@ -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:

View File

@ -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": [

View File

@ -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,

View File

@ -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,

View File

@ -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"])) {

View File

@ -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;
}

View File

@ -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 "Юридическая дата регистрации этим поиском не подтверждена.";
}

View File

@ -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");
}

View File

@ -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 {

View File

@ -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<string, unknown>): 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<string, { rows_with_amount: number; total_amount: number }>();
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<string>();
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,

View File

@ -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"
};
}

View File

@ -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;
}

View File

@ -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 "Юридическая дата регистрации этим поиском не подтверждена.";
}

View File

@ -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");
}

View File

@ -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: {

View File

@ -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: {

View File

@ -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: {

View File

@ -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",

View File

@ -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: {