From 52da709671df229693a3f6bb0b445510465ab669 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 18:42:58 +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=20supplier=20payout=20pilot=20MCP=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...alog_authority_recovery_plan_2026-04-19.md | 58 +++++++++ ...s_phase19_mcp_discovery_response_gate.json | 32 ++++- ...istantAddressLaneResponseRuntimeAdapter.js | 24 +++- .../assistantMcpDiscoveryAnswerAdapter.js | 21 +++- .../assistantMcpDiscoveryPilotExecutor.js | 74 +++++++++--- .../assistantMcpDiscoveryResponseCandidate.js | 19 +++ .../assistantMcpDiscoveryResponsePolicy.js | 14 ++- .../assistantMcpDiscoveryTurnInputAdapter.js | 13 ++- ...istantAddressLaneResponseRuntimeAdapter.ts | 24 +++- .../assistantMcpDiscoveryAnswerAdapter.ts | 27 ++++- .../assistantMcpDiscoveryPilotExecutor.ts | 110 +++++++++++++++--- .../assistantMcpDiscoveryResponseCandidate.ts | 19 +++ .../assistantMcpDiscoveryResponsePolicy.ts | 20 +++- .../assistantMcpDiscoveryTurnInputAdapter.ts | 17 ++- ...tAddressLaneResponseRuntimeAdapter.test.ts | 74 ++++++++++++ ...assistantMcpDiscoveryAnswerAdapter.test.ts | 29 +++++ ...assistantMcpDiscoveryPilotExecutor.test.ts | 64 ++++++++++ ...stantMcpDiscoveryResponseCandidate.test.ts | 36 ++++++ ...ssistantMcpDiscoveryResponsePolicy.test.ts | 39 +++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 22 ++++ 20 files changed, 688 insertions(+), 48 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 ec17572..d125ec1 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 @@ -1154,6 +1154,64 @@ Module progress: - Big Block 5 MCP Semantic Data Agent: `97%`. +## Progress Update - 2026-04-20 MCP Discovery Supplier Payout Pilot And Address-Lane Arbitration + +The seventeenth implementation slice of Big Block 5 closes the main boundary left by the first value-flow pilot: outgoing supplier payments. + +New behavior: + +- raw payout/outflow wording such as `мы заплатили`, `заплатили`, `перечислили`, `списание`, `расход`, `поставщик`, `supplier`, `payout`, and `outflow` is recognized as counterparty value-flow discovery; +- the turn input adapter now marks these turns as `asked_action_family=payout` and `unsupported_but_understood_family=counterparty_payouts_or_outflow`; +- the pilot chooses `supplier_payouts_profile` instead of `customer_revenue_and_payments`; +- `derived_value_flow` now includes `value_flow_direction`, so answer composition can distinguish incoming customer revenue from outgoing supplier payout; +- supplier payout evidence gets a separate pilot scope: `counterparty_supplier_payout_query_movements_v1`; +- the answer says `исходящие платежи/списания`, not generic turnover, and bounds the result to found rows and the checked period. +- if the probe row limit is reached, `derived_value_flow.coverage_limited_by_probe_limit=true` and the answer explicitly says that full requested-period coverage is not proven. + +The live replay exposed one more architecture gap before the final fix: + +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun7` failed on `сколько мы заплатили Группа СВК за 2020 год?`; +- MCP discovery had already produced the correct `counterparty_supplier_payout_query_movements_v1` candidate; +- the visible answer still came from stale `list_documents_by_counterparty` carryover and showed incoming bank receipt documents instead of outgoing payments. + +The response policy gate was therefore extended into the address lane. + +It may now replace an address-lane answer only when: + +- the MCP discovery entry point is `bridge_executed`; +- discovery was attempted; +- `turn_input.should_run_discovery=true`; +- the current reply source is `address_query_runtime_v1`, `address_lane`, or `address_exact`; +- the response candidate passes the same guarded text checks as living-chat and deep gates. + +This keeps exact supported routes safe because ordinary exact turns have discovery `skipped_not_applicable` or `should_run_discovery=false`. + +Replay result: + +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun8` passed 6/6, final status `accepted`; +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun9` passed 6/6 after adding the explicit row-limit coverage warning; +- the supplier payout step now answers through `mcp_discovery_response_candidate_guarded`; +- the answer reports `19 568 878,06 руб.` across `100 из 100` found outgoing payment rows for `Группа СВК` in `2020`; +- first found movement date: `2020-01-09`; +- latest found movement date: `2020-03-16`; +- because the probe reached the row limit, the answer now says that full requested-period coverage is not confirmed; +- the answer explicitly says that full outgoing payments outside the checked period are not confirmed; +- lifecycle, incoming value-flow, supplier payout, and off-domain steps did not leak `query_documents`, `query_movements`, `runtime_`, `planner_`, `catalog_`, `primitive`, or `pilot_` in user-facing text. + +Validation: + +- `npm test -- assistantMcpDiscoveryTurnInputAdapter.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` passed 49/49; +- `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_rerun9 --timeout-seconds 180` passed 6/6, final status `accepted`. + +Known remaining boundary: + +- this still does not implement arbitrary cross-register self-navigation by Qwen3. The assistant has two guarded value-flow pilots now: incoming customer revenue and outgoing supplier payouts. Bidirectional netting, multi-axis aggregation, follow-up drilldown over the discovered rows, and richer coverage proofs remain future discovery-layer work. + +Module progress: + +- Big Block 5 MCP Semantic Data Agent: `98%`. + ## 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 4bd1aef..f465d99 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 @@ -111,7 +111,37 @@ ] }, { - "step_id": "step_05_off_domain_living_chat_not_hijacked", + "step_id": "step_05_counterparty_supplier_payout_uses_guarded_discovery", + "title": "Unsupported-but-understood counterparty payout question uses guarded supplier discovery answer", + "question": "сколько мы заплатили Группа СВК за 2020 год?", + "required_answer_patterns_all": [ + "(?i)свк", + "(?i)1с|найден|строк|подтвержд", + "(?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_supplier_payout", + "counterparty_outgoing_payments", + "unsupported_current_turn_meaning_boundary" + ] + }, + { + "step_id": "step_06_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/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index eaac231..40d40e6 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -5,6 +5,7 @@ const assistantAddressTurnFinalizeRuntimeAdapter_1 = require("./assistantAddress const assistantCapabilityBindingResponseGuard_1 = require("./assistantCapabilityBindingResponseGuard"); const assistantCapabilityRuntimeBindingAdapter_1 = require("./assistantCapabilityRuntimeBindingAdapter"); const assistantMcpDiscoveryDebugAttachment_1 = require("./assistantMcpDiscoveryDebugAttachment"); +const assistantMcpDiscoveryResponsePolicy_1 = require("./assistantMcpDiscoveryResponsePolicy"); const assistantRuntimeContractResolver_1 = require("./assistantRuntimeContractResolver"); const assistantStateTransitionRuntimeAdapter_1 = require("./assistantStateTransitionRuntimeAdapter"); const assistantTruthAnswerPolicyRuntimeAdapter_1 = require("./assistantTruthAnswerPolicyRuntimeAdapter"); @@ -221,14 +222,29 @@ function runAssistantAddressLaneResponseRuntime(input) { ...debugWithMcpDiscovery, capability_binding_response_guard: guardedResponse.audit }; + const mcpDiscoveryResponsePolicy = (0, assistantMcpDiscoveryResponsePolicy_1.applyAssistantMcpDiscoveryResponsePolicy)({ + currentReply: guardedResponse.assistantReply, + currentReplySource: "address_query_runtime_v1", + addressRuntimeMeta: debugWithResponseGuard + }); + const finalAssistantReply = mcpDiscoveryResponsePolicy.applied + ? mcpDiscoveryResponsePolicy.reply_text + : guardedResponse.assistantReply; + const finalReplyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : guardedResponse.replyType; + const finalDebug = { + ...debugWithResponseGuard, + mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, + mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, + mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied + }; const finalization = finalizeAddressTurnSafe({ sessionId: input.sessionId, userMessage: input.userMessage, effectiveAddressUserMessage: input.effectiveAddressUserMessage, - assistantReply: guardedResponse.assistantReply, - replyType: guardedResponse.replyType, + assistantReply: finalAssistantReply, + replyType: finalReplyType, addressLaneDebug: normalizeAddressLaneDebug(input.addressLane.debug), - debug: debugWithResponseGuard, + debug: finalDebug, carryoverMeta: normalizeCarryoverMeta(input.carryoverMeta), llmPreDecomposeMeta: normalizeLlmPreDecomposeMeta(input.llmPreDecomposeMeta), appendItem: input.appendItem, @@ -240,6 +256,6 @@ function runAssistantAddressLaneResponseRuntime(input) { }); return { response: finalization.response, - debug: debugWithResponseGuard + debug: finalDebug }; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index c60b423..4b81fdb 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -59,8 +59,15 @@ function modeFor(pilot) { } return "checked_sources_only"; } +function isValueFlowPilot(pilot) { + return (pilot.pilot_scope === "counterparty_value_flow_query_movements_v1" || + pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1"); +} function headlineFor(mode, pilot) { if (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 (mode === "confirmed_with_bounded_inference") { @@ -98,7 +105,7 @@ function buildMustNotClaim(pilot) { claims.push("Do not claim legal registration age unless a legal registration source is confirmed."); claims.push("Do not present inferred activity duration as a formally confirmed legal fact."); } - if (pilot.pilot_scope === "counterparty_value_flow_query_movements_v1") { + if (isValueFlowPilot(pilot)) { claims.push("Do not claim full all-time turnover unless the checked period and coverage prove it."); claims.push("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows."); } @@ -125,10 +132,20 @@ function derivedValueFlowConfirmedLine(pilot) { } const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : ""; const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне"; + const movementLabel = flow.value_flow_direction === "outgoing_supplier_payout" ? "исходящих платежей/списаний" : "денежных движений"; + const totalLabel = flow.value_flow_direction === "outgoing_supplier_payout" + ? "сумма исходящих платежей/списаний составляет" + : "сумма составляет"; + const caveat = flow.value_flow_direction === "outgoing_supplier_payout" + ? "Это расчет по найденным строкам 1С, а не подтверждение полного объема платежей вне проверенного окна." + : "Это расчет по найденным строкам 1С, а не подтверждение полного оборота вне проверенного окна."; const dates = flow.first_movement_date && flow.latest_movement_date ? ` Первая найденная дата движения: ${flow.first_movement_date}; последняя: ${flow.latest_movement_date}.` : ""; - return `По найденным строкам денежных движений в 1С${counterparty}${period} сумма составляет ${flow.total_amount_human_ru} Учтено строк с суммой: ${flow.rows_with_amount} из ${flow.rows_matched}.${dates} Это расчет по найденным строкам 1С, а не подтверждение полного оборота вне проверенного окна.`; + const limitCaveat = flow.coverage_limited_by_probe_limit + ? " Лимит строк проверки достигнут; полный запрошенный период может быть покрыт не полностью." + : ""; + return `По найденным строкам ${movementLabel} в 1С${counterparty}${period} ${totalLabel} ${flow.total_amount_human_ru} Учтено строк с суммой: ${flow.rows_with_amount} из ${flow.rows_matched}.${dates}${limitCaveat} ${caveat}`; } function buildAssistantMcpDiscoveryAnswerDraft(pilot) { const mode = modeFor(pilot); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index e39f9e6..3931436 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -115,6 +115,27 @@ function isValueFlowPilotEligible(planner) { combined.includes("payout") || combined.includes("value"))); } +function valueFlowPilotProfile(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const action = String(meaning?.asked_action_family ?? "").toLowerCase(); + const unsupported = String(meaning?.unsupported_but_understood_family ?? "").toLowerCase(); + const combined = `${action} ${unsupported}`; + if (combined.includes("payout") || + combined.includes("outflow") || + combined.includes("supplier") || + combined.includes("paid")) { + return { + scope: "counterparty_supplier_payout_query_movements_v1", + recipe_intent: "supplier_payouts_profile", + direction: "outgoing_supplier_payout" + }; + } + return { + scope: "counterparty_value_flow_query_movements_v1", + recipe_intent: "customer_revenue_and_payments", + direction: "incoming_customer_revenue" + }; +} function skippedProbeResult(step, limitation) { return { primitive_id: step.primitive_id, @@ -259,7 +280,7 @@ function formatAmountHumanRu(amount) { .replace(/\u00a0/g, " "); return `${formatted} руб.`; } -function deriveValueFlow(result, counterparty, periodScope) { +function deriveValueFlow(result, counterparty, periodScope, direction, probeLimit) { if (!result || result.error || result.matched_rows <= 0) { return null; } @@ -280,6 +301,7 @@ function deriveValueFlow(result, counterparty, periodScope) { .filter((value) => Boolean(value)) .sort(); return { + value_flow_direction: direction, counterparty, period_scope: periodScope, rows_matched: result.matched_rows, @@ -288,6 +310,7 @@ function deriveValueFlow(result, counterparty, periodScope) { total_amount_human_ru: formatAmountHumanRu(totalAmount), first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, + coverage_limited_by_probe_limit: result.matched_rows >= probeLimit, inference_basis: "sum_of_confirmed_1c_value_flow_rows" }; } @@ -301,10 +324,17 @@ function buildLifecycleConfirmedFacts(result, counterparty) { : "1C activity rows were found for the requested counterparty scope" ]; } -function buildValueFlowConfirmedFacts(result, counterparty) { +function buildValueFlowConfirmedFacts(result, counterparty, direction) { if (result.error || result.matched_rows <= 0) { return []; } + if (direction === "outgoing_supplier_payout") { + return [ + counterparty + ? `1C supplier-payout rows were found for counterparty ${counterparty}` + : "1C supplier-payout rows were found for the requested counterparty scope" + ]; + } return [ counterparty ? `1C value-flow rows were found for counterparty ${counterparty}` @@ -321,17 +351,29 @@ function buildValueFlowInferredFacts(derived) { if (!derived) { return []; } + if (derived.value_flow_direction === "outgoing_supplier_payout") { + return ["Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"]; + } return ["Counterparty value-flow total was calculated from confirmed 1C movement rows"]; } function buildLifecycleUnknownFacts() { return ["Legal registration date is not proven by this MCP discovery pilot"]; } -function buildValueFlowUnknownFacts(periodScope) { - return [ - periodScope - ? "Full turnover outside the checked period is not proven by this MCP discovery pilot" - : "Full all-time turnover is not proven without an explicit checked period" - ]; +function buildValueFlowUnknownFacts(periodScope, direction, derived) { + const unknownFacts = []; + if (derived?.coverage_limited_by_probe_limit) { + unknownFacts.push("Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached"); + } + if (direction === "outgoing_supplier_payout") { + unknownFacts.push(periodScope + ? "Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot" + : "Full all-time supplier-payout amount is not proven without an explicit checked period"); + return unknownFacts; + } + unknownFacts.push(periodScope + ? "Full turnover outside the checked period is not proven by this MCP discovery pilot" + : "Full all-time turnover is not proven without an explicit checked period"); + return unknownFacts; } function buildEmptyEvidence(planner, dryRun, probeResults, reason) { return (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ @@ -423,7 +465,8 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { if (valueFlowPilotEligible) { let queryResult = null; const filters = buildValueFlowFilters(planner); - const selection = (0, addressRecipeCatalog_1.selectAddressRecipe)("customer_revenue_and_payments", filters); + const valueFlowProfile = valueFlowPilotProfile(planner); + const selection = (0, addressRecipeCatalog_1.selectAddressRecipe)(valueFlowProfile.recipe_intent, filters); if (!selection.selected_recipe) { pushReason(reasonCodes, "pilot_value_flow_recipe_not_available"); const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "Value-flow recipe is not available"); @@ -431,7 +474,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryPilotExecutor", pilot_status: "unsupported", - pilot_scope: "counterparty_value_flow_query_movements_v1", + pilot_scope: valueFlowProfile.scope, dry_run: dryRun, mcp_execution_performed: false, executed_primitives: executedPrimitives, @@ -445,6 +488,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { reason_codes: reasonCodes }; } + pushReason(reasonCodes, valueFlowProfile.direction === "outgoing_supplier_payout" + ? "pilot_supplier_payout_recipe_selected" + : "pilot_customer_revenue_recipe_selected"); const recipePlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(selection.selected_recipe, filters); for (const step of dryRun.execution_steps) { if (step.primitive_id !== "query_movements") { @@ -468,16 +514,16 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { } } const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null; - const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope); + const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope, valueFlowProfile.direction, planner.discovery_plan.execution_budget.max_rows_per_probe); if (derivedValueFlow) { pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows"); } const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildValueFlowConfirmedFacts(queryResult, counterparty) : [], + confirmedFacts: queryResult ? buildValueFlowConfirmedFacts(queryResult, counterparty, valueFlowProfile.direction) : [], inferredFacts: buildValueFlowInferredFacts(derivedValueFlow), - unknownFacts: buildValueFlowUnknownFacts(dateScope), + unknownFacts: buildValueFlowUnknownFacts(dateScope, valueFlowProfile.direction, derivedValueFlow), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" @@ -486,7 +532,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryPilotExecutor", pilot_status: "executed", - pilot_scope: "counterparty_value_flow_query_movements_v1", + pilot_scope: valueFlowProfile.scope, dry_run: dryRun, mcp_execution_performed: executedPrimitives.length > 0, executed_primitives: executedPrimitives, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 477ee33..795fe51 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -75,21 +75,40 @@ function localizeLine(value) { if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки денежных движений по запрошенному контрагентскому контуру."; } + const supplierPayoutMatch = value.match(/^1C supplier-payout rows were found for counterparty\s+(.+)$/i); + if (supplierPayoutMatch) { + return `В 1С найдены строки исходящих платежей/списаний по контрагенту ${supplierPayoutMatch[1]}.`; + } + if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) { + return "В 1С найдены строки исходящих платежей/списаний по запрошенному контрагентскому контуру."; + } if (/^Business activity duration may be inferred from first and latest confirmed 1C activity rows$/i.test(value)) { return "Длительность деловой активности можно оценивать только как вывод по первой и последней подтвержденной строке активности в 1С."; } if (/^Counterparty value-flow total was calculated from 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 (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { return "Юридическая дата регистрации этим поиском не подтверждена."; } + if (/^Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached$/i.test(value)) { + return "Полное покрытие запрошенного периода не подтверждено: проверка достигла лимита найденных строк."; + } if (/^Full turnover outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { return "Полный оборот вне проверенного периода этим поиском не подтвержден."; } if (/^Full all-time turnover is not proven without an explicit checked period$/i.test(value)) { return "Полный оборот за все время без явно проверенного периода не подтвержден."; } + if (/^Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { + return "Полный объем исходящих платежей вне проверенного периода этим поиском не подтвержден."; + } + if (/^Full all-time supplier-payout amount is not proven without an explicit checked period$/i.test(value)) { + return "Полный объем исходящих платежей за все время без явно проверенного периода не подтвержден."; + } return value; } function section(title, lines) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index eea87d0..a002078 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -81,6 +81,14 @@ function isDiscoveryReadyDeepCandidate(input, entryPoint) { turnInput?.should_run_discovery === true && (source === "deep_analysis" || source === "partial_coverage" || source === "normalizer_v2_0_2")); } +function isDiscoveryReadyAddressCandidate(input, entryPoint) { + const turnInput = toRecordObject(entryPoint?.turn_input); + const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase(); + return (entryPoint?.entry_status === "bridge_executed" && + entryPoint.discovery_attempted === true && + turnInput?.should_run_discovery === true && + (source === "address_lane" || source === "address_exact" || source === "address_query_runtime_v1")); +} function applyAssistantMcpDiscoveryResponsePolicy(input) { const currentReply = String(input.currentReply ?? ""); const currentReplySource = toNonEmptyString(input.currentReplySource) ?? toNonEmptyString(input.livingChatSource) ?? "unknown"; @@ -90,6 +98,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const unsupportedBoundary = isUnsupportedCurrentTurnBoundary(input); const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); + const discoveryReadyAddressCandidate = isDiscoveryReadyAddressCandidate(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); } @@ -102,6 +111,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (!discoveryReadyDeepCandidate) { pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_deep_candidate"); } + if (!discoveryReadyAddressCandidate) { + pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_address_candidate"); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -115,7 +127,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_contains_internal_mechanics"); } const canApply = Boolean(entryPoint) && - (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate) && + (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && Boolean(toNonEmptyString(candidate.reply_text)) && diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 581a8c5..f8a0c76 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -96,7 +96,10 @@ function hasLifecycleSignal(text) { return /(?:сколько\s+лет|как\s+давно|давно\s+ли|возраст|перв(?:ая|ый)\s+актив|когда\s+начал|когда\s+появ|lifecycle|activity\s+duration|business\s+age|how\s+long)/iu.test(text); } function hasValueFlowSignal(text) { - return /(?:оборот|выручк|оплат|плат[её]ж|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|value[-\s]?flow|turnover|revenue|payment|payout|cash\s+flow)/iu.test(text); + return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow)/iu.test(text); +} +function hasPayoutSignal(text) { + return /(?:\bмы\s+(?:за)?плат|(?:за)?платил|оплатил|перечисл|списан|расход|поставщик|исходящ|supplier|payout|outflow|paid\s+to|payment\s+to)/iu.test(text); } function semanticNeedFor(input) { const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); @@ -134,6 +137,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); const lifecycleSignal = hasLifecycleSignal(rawText); const valueFlowSignal = !lifecycleSignal && hasValueFlowSignal(rawText); + const payoutSignal = valueFlowSignal && hasPayoutSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); @@ -153,11 +157,11 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const explicitOrganizationScope = valueFlowSignal && !predecomposeEntities.counterparty ? null : predecomposeEntities.organization; const turnMeaning = { asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" : rawDomain, - asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal ? "turnover" : rawAction, + asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal ? (payoutSignal ? "payout" : "turnover") : rawAction, explicit_entity_candidates: entityCandidates, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: collectDateScope(predecomposeContract), - unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value_or_turnover" : null), + unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? (payoutSignal ? "counterparty_payouts_or_outflow" : "counterparty_value_or_turnover") : null), stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal || valueFlowSignal) }; const cleanTurnMeaning = {}; @@ -205,6 +209,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (valueFlowSignal) { pushReason(reasonCodes, "mcp_discovery_value_flow_signal_detected"); } + if (payoutSignal) { + pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index b925d14..0d5a0e6 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -10,6 +10,7 @@ import { import { applyAssistantCapabilityBindingResponseGuard } from "./assistantCapabilityBindingResponseGuard"; import { attachAssistantCapabilityRuntimeBinding } from "./assistantCapabilityRuntimeBindingAdapter"; import { attachAssistantMcpDiscoveryDebug } from "./assistantMcpDiscoveryDebugAttachment"; +import { applyAssistantMcpDiscoveryResponsePolicy } from "./assistantMcpDiscoveryResponsePolicy"; import { attachAssistantRuntimeContractShadow } from "./assistantRuntimeContractResolver"; import { attachAssistantStateTransition } from "./assistantStateTransitionRuntimeAdapter"; import { attachAssistantTruthAnswerPolicy } from "./assistantTruthAnswerPolicyRuntimeAdapter"; @@ -280,14 +281,29 @@ export function runAssistantAddressLaneResponseRuntime; + recipe_intent: Extract; + direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"]; +} + +function valueFlowPilotProfile(planner: AssistantMcpDiscoveryPlannerContract): ValueFlowPilotProfile { + const meaning = planner.discovery_plan.turn_meaning_ref; + const action = String(meaning?.asked_action_family ?? "").toLowerCase(); + const unsupported = String(meaning?.unsupported_but_understood_family ?? "").toLowerCase(); + const combined = `${action} ${unsupported}`; + if ( + combined.includes("payout") || + combined.includes("outflow") || + combined.includes("supplier") || + combined.includes("paid") + ) { + return { + scope: "counterparty_supplier_payout_query_movements_v1", + recipe_intent: "supplier_payouts_profile", + direction: "outgoing_supplier_payout" + }; + } + return { + scope: "counterparty_value_flow_query_movements_v1", + recipe_intent: "customer_revenue_and_payments", + direction: "incoming_customer_revenue" + }; +} + function skippedProbeResult(step: AssistantMcpDiscoveryRuntimeStepContract, limitation: string): AssistantMcpDiscoveryProbeResult { return { primitive_id: step.primitive_id, @@ -361,7 +397,9 @@ function formatAmountHumanRu(amount: number): string { function deriveValueFlow( result: AddressMcpQueryExecutorResult | null, counterparty: string | null, - periodScope: string | null + periodScope: string | null, + direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"], + probeLimit: number ): AssistantMcpDiscoveryDerivedValueFlow | null { if (!result || result.error || result.matched_rows <= 0) { return null; @@ -383,6 +421,7 @@ function deriveValueFlow( .filter((value): value is string => Boolean(value)) .sort(); return { + value_flow_direction: direction, counterparty, period_scope: periodScope, rows_matched: result.matched_rows, @@ -391,6 +430,7 @@ function deriveValueFlow( total_amount_human_ru: formatAmountHumanRu(totalAmount), first_movement_date: dates[0] ?? null, latest_movement_date: dates[dates.length - 1] ?? null, + coverage_limited_by_probe_limit: result.matched_rows >= probeLimit, inference_basis: "sum_of_confirmed_1c_value_flow_rows" }; } @@ -406,10 +446,21 @@ function buildLifecycleConfirmedFacts(result: AddressMcpQueryExecutorResult, cou ]; } -function buildValueFlowConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { +function buildValueFlowConfirmedFacts( + result: AddressMcpQueryExecutorResult, + counterparty: string | null, + direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"] +): string[] { if (result.error || result.matched_rows <= 0) { return []; } + if (direction === "outgoing_supplier_payout") { + return [ + counterparty + ? `1C supplier-payout rows were found for counterparty ${counterparty}` + : "1C supplier-payout rows were found for the requested counterparty scope" + ]; + } return [ counterparty ? `1C value-flow rows were found for counterparty ${counterparty}` @@ -428,6 +479,9 @@ function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueF if (!derived) { return []; } + if (derived.value_flow_direction === "outgoing_supplier_payout") { + return ["Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"]; + } return ["Counterparty value-flow total was calculated from confirmed 1C movement rows"]; } @@ -435,12 +489,29 @@ function buildLifecycleUnknownFacts(): string[] { return ["Legal registration date is not proven by this MCP discovery pilot"]; } -function buildValueFlowUnknownFacts(periodScope: string | null): string[] { - return [ +function buildValueFlowUnknownFacts( + periodScope: string | null, + direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"], + derived: AssistantMcpDiscoveryDerivedValueFlow | null +): string[] { + const unknownFacts: string[] = []; + if (derived?.coverage_limited_by_probe_limit) { + unknownFacts.push("Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached"); + } + if (direction === "outgoing_supplier_payout") { + unknownFacts.push( + periodScope + ? "Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot" + : "Full all-time supplier-payout amount is not proven without an explicit checked period" + ); + return unknownFacts; + } + unknownFacts.push( periodScope ? "Full turnover outside the checked period is not proven by this MCP discovery pilot" : "Full all-time turnover is not proven without an explicit checked period" - ]; + ); + return unknownFacts; } function buildEmptyEvidence( @@ -548,7 +619,8 @@ export async function executeAssistantMcpDiscoveryPilot( if (valueFlowPilotEligible) { let queryResult: AddressMcpQueryExecutorResult | null = null; const filters = buildValueFlowFilters(planner); - const selection = selectAddressRecipe("customer_revenue_and_payments", filters); + const valueFlowProfile = valueFlowPilotProfile(planner); + const selection = selectAddressRecipe(valueFlowProfile.recipe_intent, filters); if (!selection.selected_recipe) { pushReason(reasonCodes, "pilot_value_flow_recipe_not_available"); const evidence = buildEmptyEvidence(planner, dryRun, probeResults, "Value-flow recipe is not available"); @@ -556,7 +628,7 @@ export async function executeAssistantMcpDiscoveryPilot( schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryPilotExecutor", pilot_status: "unsupported", - pilot_scope: "counterparty_value_flow_query_movements_v1", + pilot_scope: valueFlowProfile.scope, dry_run: dryRun, mcp_execution_performed: false, executed_primitives: executedPrimitives, @@ -570,6 +642,12 @@ export async function executeAssistantMcpDiscoveryPilot( reason_codes: reasonCodes }; } + pushReason( + reasonCodes, + valueFlowProfile.direction === "outgoing_supplier_payout" + ? "pilot_supplier_payout_recipe_selected" + : "pilot_customer_revenue_recipe_selected" + ); const recipePlan = buildAddressRecipePlan(selection.selected_recipe, filters); for (const step of dryRun.execution_steps) { @@ -594,16 +672,22 @@ export async function executeAssistantMcpDiscoveryPilot( } const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null; - const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope); + const derivedValueFlow = deriveValueFlow( + queryResult, + counterparty, + dateScope, + valueFlowProfile.direction, + planner.discovery_plan.execution_budget.max_rows_per_probe + ); if (derivedValueFlow) { pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows"); } const evidence = resolveAssistantMcpDiscoveryEvidence({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildValueFlowConfirmedFacts(queryResult, counterparty) : [], + confirmedFacts: queryResult ? buildValueFlowConfirmedFacts(queryResult, counterparty, valueFlowProfile.direction) : [], inferredFacts: buildValueFlowInferredFacts(derivedValueFlow), - unknownFacts: buildValueFlowUnknownFacts(dateScope), + unknownFacts: buildValueFlowUnknownFacts(dateScope, valueFlowProfile.direction, derivedValueFlow), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" @@ -613,7 +697,7 @@ export async function executeAssistantMcpDiscoveryPilot( schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryPilotExecutor", pilot_status: "executed", - pilot_scope: "counterparty_value_flow_query_movements_v1", + pilot_scope: valueFlowProfile.scope, dry_run: dryRun, mcp_execution_performed: executedPrimitives.length > 0, executed_primitives: executedPrimitives, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index ddcd197..ca1f185 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -105,21 +105,40 @@ function localizeLine(value: string): string { if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки денежных движений по запрошенному контрагентскому контуру."; } + const supplierPayoutMatch = value.match(/^1C supplier-payout rows were found for counterparty\s+(.+)$/i); + if (supplierPayoutMatch) { + return `В 1С найдены строки исходящих платежей/списаний по контрагенту ${supplierPayoutMatch[1]}.`; + } + if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) { + return "В 1С найдены строки исходящих платежей/списаний по запрошенному контрагентскому контуру."; + } if (/^Business activity duration may be inferred from first and latest confirmed 1C activity rows$/i.test(value)) { return "Длительность деловой активности можно оценивать только как вывод по первой и последней подтвержденной строке активности в 1С."; } if (/^Counterparty value-flow total was calculated from 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 (/^Legal registration date is not proven by this MCP discovery pilot$/i.test(value)) { return "Юридическая дата регистрации этим поиском не подтверждена."; } + if (/^Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached$/i.test(value)) { + return "Полное покрытие запрошенного периода не подтверждено: проверка достигла лимита найденных строк."; + } if (/^Full turnover outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { return "Полный оборот вне проверенного периода этим поиском не подтвержден."; } if (/^Full all-time turnover is not proven without an explicit checked period$/i.test(value)) { return "Полный оборот за все время без явно проверенного периода не подтвержден."; } + if (/^Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { + return "Полный объем исходящих платежей вне проверенного периода этим поиском не подтвержден."; + } + if (/^Full all-time supplier-payout amount is not proven without an explicit checked period$/i.test(value)) { + return "Полный объем исходящих платежей за все время без явно проверенного периода не подтвержден."; + } return value; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 414eace..d207ad5 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -137,6 +137,20 @@ function isDiscoveryReadyDeepCandidate( ); } +function isDiscoveryReadyAddressCandidate( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const turnInput = toRecordObject(entryPoint?.turn_input); + const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase(); + return ( + entryPoint?.entry_status === "bridge_executed" && + entryPoint.discovery_attempted === true && + turnInput?.should_run_discovery === true && + (source === "address_lane" || source === "address_exact" || source === "address_query_runtime_v1") + ); +} + export function applyAssistantMcpDiscoveryResponsePolicy( input: ApplyAssistantMcpDiscoveryResponsePolicyInput ): AssistantMcpDiscoveryResponsePolicyResult { @@ -149,6 +163,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const unsupportedBoundary = isUnsupportedCurrentTurnBoundary(input); const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); + const discoveryReadyAddressCandidate = isDiscoveryReadyAddressCandidate(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); @@ -162,6 +177,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (!discoveryReadyDeepCandidate) { pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_deep_candidate"); } + if (!discoveryReadyAddressCandidate) { + pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_address_candidate"); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -177,7 +195,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const canApply = Boolean(entryPoint) && - (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate) && + (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate || discoveryReadyAddressCandidate) && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && Boolean(toNonEmptyString(candidate.reply_text)) && diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index ce560fd..ac50a0e 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -139,7 +139,13 @@ function hasLifecycleSignal(text: string): boolean { } function hasValueFlowSignal(text: string): boolean { - return /(?:оборот|выручк|оплат|плат[её]ж|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|value[-\s]?flow|turnover|revenue|payment|payout|cash\s+flow)/iu.test( + return /(?:оборот|выручк|оплат|плат[её]ж|заплат|перечисл|списан|расход|исходящ|поступлен|денежн[а-яёa-z0-9_-]*\s+поток|supplier|value[-\s]?flow|turnover|revenue|payment|payout|outflow|cash\s+flow)/iu.test( + text + ); +} + +function hasPayoutSignal(text: string): boolean { + return /(?:\bмы\s+(?:за)?плат|(?:за)?платил|оплатил|перечисл|списан|расход|поставщик|исходящ|supplier|payout|outflow|paid\s+to|payment\s+to)/iu.test( text ); } @@ -196,6 +202,7 @@ export function buildAssistantMcpDiscoveryTurnInput( const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); const lifecycleSignal = hasLifecycleSignal(rawText); const valueFlowSignal = !lifecycleSignal && hasValueFlowSignal(rawText); + const payoutSignal = valueFlowSignal && hasPayoutSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); @@ -218,11 +225,12 @@ export function buildAssistantMcpDiscoveryTurnInput( const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = { asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" : rawDomain, - asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal ? "turnover" : rawAction, + asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal ? (payoutSignal ? "payout" : "turnover") : rawAction, explicit_entity_candidates: entityCandidates, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: collectDateScope(predecomposeContract), - unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value_or_turnover" : null), + unsupported_but_understood_family: + unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? (payoutSignal ? "counterparty_payouts_or_outflow" : "counterparty_value_or_turnover") : null), stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal || valueFlowSignal) }; @@ -273,6 +281,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (valueFlowSignal) { pushReason(reasonCodes, "mcp_discovery_value_flow_signal_detected"); } + if (payoutSignal) { + pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts index 7d6127d..afb09e0 100644 --- a/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLaneResponseRuntimeAdapter.test.ts @@ -162,6 +162,80 @@ describe("assistant address lane response runtime adapter", () => { ); }); + it("can replace a stale address exact answer with a guarded MCP discovery candidate", () => { + const finalizeAddressTurn = vi.fn((input) => ({ + response: { + ok: true, + assistant_reply: input.assistantReply, + reply_type: input.replyType, + debug: input.debug + } + })); + + const runtime = runAssistantAddressLaneResponseRuntime({ + sessionId: "asst-mcp-address", + userMessage: "сколько мы заплатили СВК за 2020?", + effectiveAddressUserMessage: "сколько мы заплатили СВК за 2020?", + addressLane: { + handled: true, + reply_text: "stale documents answer", + reply_type: "factual", + debug: {} + }, + llmPreDecomposeMeta: { + mcpDiscoveryRuntimeEntryPoint: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "bridge_executed", + hot_runtime_wired: false, + discovery_attempted: true, + turn_input: { adapter_status: "ready", should_run_discovery: true }, + 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: "Discovery payout answer", + confirmed_lines: ["1C supplier-payout rows were found for counterparty SVK"], + inference_lines: ["Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"], + unknown_lines: ["Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot"], + limitation_lines: [], + next_step_line: null + } + }, + reason_codes: ["runtime_entry_point_bridge_executed"] + } + }, + knownOrganizations: [], + activeOrganization: null, + sanitizeOutgoingAssistantText: (text) => String(text ?? ""), + buildAddressDebugPayload: () => ({}), + buildAddressFollowupOffer: () => null, + mergeKnownOrganizations: (items) => items, + toNonEmptyString: () => null, + appendItem: () => {}, + getSession: () => ({ session_id: "asst-mcp-address", updated_at: "", items: [], investigation_state: null } as any), + persistSession: () => {}, + cloneConversation: (items) => items, + logEvent: () => {}, + messageIdFactory: () => "msg-mcp-address", + finalizeAddressTurn + }); + + expect(finalizeAddressTurn).toHaveBeenCalledWith( + expect.objectContaining({ + assistantReply: expect.stringContaining("Discovery payout answer"), + replyType: "partial_coverage", + debug: expect.objectContaining({ + mcp_discovery_response_applied: true + }) + }) + ); + expect(String((runtime.response as any).assistant_reply)).toContain("исходящих платежей/списаний"); + }); + it("keeps debug bounded to shadow contracts when optional enrichment is absent", () => { const runtime = runAssistantAddressLaneResponseRuntime({ sessionId: "asst-2", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 168c363..d076c0c 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -110,6 +110,35 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.limitation_lines.join("\n")).not.toContain("pilot_"); }); + it("turns supplier payout evidence into a bounded outgoing payment answer draft", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildDeps([ + { Period: "2020-03-15T00:00:00", Amount: 4100, Counterparty: "SVK" }, + { Period: "2020-04-20T00:00:00", Amount: "900,25", 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("исходящих платежей/списаний"); + expect(confirmedText).toContain("5 000,25 руб."); + expect(draft.inference_lines.join("\n")).toContain("supplier-payout total"); + expect(draft.unknown_lines).toContain("Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot"); + }); + 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 87592e2..de1bd16 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -106,6 +106,7 @@ describe("assistant MCP discovery pilot executor", () => { rows_matched: 2, rows_with_amount: 2, total_amount: 3750.5, + coverage_limited_by_probe_limit: false, first_movement_date: "2020-01-15", latest_movement_date: "2020-02-20", inference_basis: "sum_of_confirmed_1c_value_flow_rows" @@ -119,6 +120,69 @@ describe("assistant MCP discovery pilot executor", () => { expect(call?.limit).toBeGreaterThan(0); }); + it("executes supplier payout query_movements and derives a guarded outgoing payment sum", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + } + }); + const deps = buildDeps([ + { Period: "2020-03-15T00:00:00", Amount: 4100, Counterparty: "SVK" }, + { Period: "2020-04-20T00:00:00", Amount: "900,25", Counterparty: "SVK" } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.pilot_scope).toBe("counterparty_supplier_payout_query_movements_v1"); + expect(result.derived_value_flow).toMatchObject({ + value_flow_direction: "outgoing_supplier_payout", + counterparty: "SVK", + period_scope: "2020", + rows_matched: 2, + rows_with_amount: 2, + total_amount: 5000.25, + coverage_limited_by_probe_limit: false, + first_movement_date: "2020-03-15", + latest_movement_date: "2020-04-20" + }); + expect(result.evidence.confirmed_facts[0]).toContain("supplier-payout rows"); + expect(result.evidence.inferred_facts[0]).toContain("supplier-payout total"); + expect(result.evidence.unknown_facts).toContain("Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot"); + expect(result.reason_codes).toContain("pilot_supplier_payout_recipe_selected"); + + const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0]; + expect(String(call?.query ?? "")).toContain("СписаниеСРасчетногоСчета"); + }); + + it("marks value-flow coverage as limited when the probe row limit is reached", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + } + }); + const rows = Array.from({ length: 100 }, (_, index) => ({ + Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, + Amount: 10, + Counterparty: "SVK" + })); + + const result = await executeAssistantMcpDiscoveryPilot(planner, buildDeps(rows)); + + expect(result.derived_value_flow?.coverage_limited_by_probe_limit).toBe(true); + expect(result.evidence.unknown_facts).toContain( + "Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached" + ); + }); + it("keeps non-lifecycle ready plans unsupported until a dedicated pilot exists", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index e78226b..1f2248b 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -76,6 +76,42 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("query_movements"); }); + it("localizes supplier-payout evidence without leaking pilot mechanics", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "По данным 1С найдены строки исходящих платежей/списаний; сумму можно называть только в рамках проверенного периода и найденных строк.", + confirmed_lines: [ + "1C supplier-payout rows were found for counterparty SVK", + "По найденным строкам исходящих платежей/списаний в 1С по контрагенту SVK за период 2020 сумма исходящих платежей/списаний составляет 5 000 руб." + ], + inference_lines: ["Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"], + unknown_lines: [ + "Complete requested-period coverage is not proven because the MCP discovery probe row limit was reached", + "Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot" + ], + limitation_lines: ["pilot_value_flow_uses_query_movements_and_derives_aggregate"], + next_step_line: null + } + } + }) + ); + + expect(candidate.candidate_status).toBe("ready_for_guarded_use"); + expect(candidate.reply_text).toContain("В 1С найдены строки исходящих платежей/списаний по контрагенту SVK."); + expect(candidate.reply_text).toContain("5 000 руб."); + expect(candidate.reply_text).toContain("Полное покрытие запрошенного периода не подтверждено"); + expect(candidate.reply_text).toContain("Полный объем исходящих платежей вне проверенного периода этим поиском не подтвержден."); + expect(candidate.reply_text).not.toContain("pilot_"); + expect(candidate.reply_text).not.toContain("query_movements"); + }); + 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/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index b3ac245..6b8565c 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -109,6 +109,45 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_deep_candidate"); }); + it("applies a guarded candidate for discovery-ready address lane answers", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "stale exact route answer", + currentReplySource: "address_query_runtime_v1", + addressRuntimeMeta: { + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.reply_source).toBe("mcp_discovery_response_candidate_guarded"); + expect(result.reply_text).toContain("Confirmed fact"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate"); + }); + + it("keeps address lane answers when discovery was not requested for the current turn", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "supported exact route answer", + currentReplySource: "address_query_runtime_v1", + addressRuntimeMeta: { + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "not_applicable", + should_run_discovery: false + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.reply_text).toBe("supported exact route answer"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate"); + }); + it("keeps the current reply when the candidate has no grounded text", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "route is not wired", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index d17a35b..4105b1f 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -86,6 +86,28 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.turn_meaning_ref?.explicit_organization_scope).toBeUndefined(); }); + it("keeps payout wording as outgoing supplier-payout discovery", () => { + 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.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_payout_signal_detected"); + }); + it("does not activate discovery for supported exact current-turn intent", () => { const result = buildAssistantMcpDiscoveryTurnInput({ assistantTurnMeaning: {