From 49fd08652c8b7c3786b67b614adaa4e4468c36d1 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 20 Apr 2026 18:15:26 +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=20value-flow=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 | 63 ++++ ...s_phase19_mcp_discovery_response_gate.json | 31 +- ...assistantDeepTurnResponseRuntimeAdapter.js | 41 ++- .../assistantMcpDiscoveryAnswerAdapter.js | 36 ++- .../assistantMcpDiscoveryPilotExecutor.js | 238 ++++++++++++++- .../assistantMcpDiscoveryResponseCandidate.js | 17 ++ .../assistantMcpDiscoveryResponsePolicy.js | 15 +- .../assistantMcpDiscoveryTurnInputAdapter.js | 34 ++- ...assistantDeepTurnResponseRuntimeAdapter.ts | 42 ++- .../assistantMcpDiscoveryAnswerAdapter.ts | 38 ++- .../assistantMcpDiscoveryPilotExecutor.ts | 277 +++++++++++++++++- .../assistantMcpDiscoveryResponseCandidate.ts | 17 ++ .../assistantMcpDiscoveryResponsePolicy.ts | 21 +- .../assistantMcpDiscoveryTurnInputAdapter.ts | 38 ++- ...tantDeepTurnResponseRuntimeAdapter.test.ts | 87 +++++- ...assistantMcpDiscoveryAnswerAdapter.test.ts | 30 ++ ...assistantMcpDiscoveryPilotExecutor.test.ts | 43 +++ ...stantMcpDiscoveryResponseCandidate.test.ts | 32 ++ ...ssistantMcpDiscoveryResponsePolicy.test.ts | 20 ++ ...stantMcpDiscoveryRuntimeEntryPoint.test.ts | 9 +- ...istantMcpDiscoveryTurnInputAdapter.test.ts | 37 +++ 21 files changed, 1100 insertions(+), 66 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 3f1bd72..ec17572 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 @@ -1091,6 +1091,69 @@ Validation: - `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_rerun3 --timeout-seconds 180` passed 4/4, final status `accepted`. +## Progress Update - 2026-04-20 MCP Discovery Value-Flow Pilot And Deep Response Gate + +The sixteenth implementation slice of Big Block 5 extends guarded MCP discovery from lifecycle evidence to the first bounded value-flow evidence path: + +- `assistantMcpDiscoveryTurnInputAdapter.ts` +- `assistantMcpDiscoveryPilotExecutor.ts` +- `assistantMcpDiscoveryAnswerAdapter.ts` +- `assistantMcpDiscoveryResponseCandidate.ts` +- `assistantMcpDiscoveryResponsePolicy.ts` +- `assistantDeepTurnResponseRuntimeAdapter.ts` +- `address_truth_harness_phase19_mcp_discovery_response_gate.json` + +The new path is deliberately narrow. + +It does not turn the assistant into a free-form autonomous 1C agent yet. It adds a guarded pilot for counterparty value-flow questions when the exact route does not own the current turn: + +- raw Russian/English value-flow signals such as `денежный поток`, `оборот`, `выручка`, `оплата`, `turnover`, and `revenue`; +- Cyrillic-safe signal detection instead of JavaScript `\w` assumptions; +- organization-shaped targets can be reinterpreted as counterparty candidates for this contour when no explicit counterparty was extracted; +- the planner may choose value-flow primitives, but the pilot executes only the guarded `query_movements` branch through the existing `customer_revenue_and_payments` recipe; +- the pilot derives `derived_value_flow` from confirmed movement rows: counterparty, period scope, matched rows, rows with amount, total amount, first movement date, latest movement date, and `inference_basis=sum_of_confirmed_1c_value_flow_rows`; +- the user-facing answer says what was found, what was calculated, and what is not proven outside the checked period; +- internal terms such as primitive names, query ids, runtime/planner/catalog mechanics, and `pilot_` reason codes are filtered from the final answer. + +The deep response adapter now preserves the MCP discovery entry point in deep runtime meta and can replace a bad deep/partial answer with a guarded discovery candidate only when: + +- the entry point is `bridge_executed`; +- discovery was attempted; +- `turn_input.should_run_discovery=true`; +- the current reply source is `deep_analysis`, `partial_coverage`, or `normalizer_v2_0_2`; +- the response candidate passes the same guarded text checks as the living-chat gate. + +Live replay finding: + +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun4` failed after adding the value-flow question because the visible answer still came from the deep partial path and drifted into unrelated store/asset/amortization wording; +- after preserving the discovery entry point for deep packaging and allowing the guarded deep candidate, `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun5` passed 5/5; +- after the punctuation cleanup, `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun6` also passed 5/5, final status `accepted`. + +The value-flow replay answer for `какой денежный поток был у Группа СВК за 2020 год?` now states: + +- found 1C money-movement rows for `Группа СВК`; +- period: `2020`; +- calculated sum: `47 628 853,03 руб.`; +- rows with amount: `44 из 44`; +- first movement date: `2020-01-09`; +- latest movement date: `2020-12-30`; +- full turnover outside the checked period is not confirmed by this search. + +Validation: + +- `npm test -- assistantMcpDiscoveryTurnInputAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryResponsePolicy.test.ts assistantDeepTurnResponseRuntimeAdapter.test.ts assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryRuntimeBridge.test.ts` passed 37/37; +- `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_rerun6 --timeout-seconds 180` passed 5/5, final status `accepted`; +- step-level human answer review confirmed no `query_documents`, `query_movements`, `runtime_`, `planner_`, `catalog_`, `primitive`, or `pilot_` leak in lifecycle/value-flow user-facing answers. + +Known remaining boundary: + +- this slice covers incoming customer value-flow through `customer_revenue_and_payments`; supplier payouts, bidirectional money flow, arbitrary cross-register discovery, and multi-axis aggregation are still future MCP semantic discovery work. + +Module progress: + +- Big Block 5 MCP Semantic Data Agent: `97%`. + ## 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 8fc8811..4bd1aef 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 @@ -82,7 +82,36 @@ ] }, { - "step_id": "step_04_off_domain_living_chat_not_hijacked", + "step_id": "step_04_counterparty_value_flow_uses_guarded_discovery", + "title": "Unsupported-but-understood counterparty value-flow question uses guarded discovery answer", + "question": "какой денежный поток был у Группа СВК за 2020 год?", + "required_answer_patterns_all": [ + "(?i)свк", + "(?i)1с|найден|строк|подтвержд", + "(?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_value_flow", + "counterparty_turnover", + "unsupported_current_turn_meaning_boundary" + ] + }, + { + "step_id": "step_05_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/assistantDeepTurnResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js index 512fbb2..3b675d3 100644 --- a/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantDeepTurnResponseRuntimeAdapter.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.runAssistantDeepTurnResponseRuntime = runAssistantDeepTurnResponseRuntime; const assistantDeepTurnPackagingRuntimeAdapter_1 = require("./assistantDeepTurnPackagingRuntimeAdapter"); const assistantDeepTurnFinalizeRuntimeAdapter_1 = require("./assistantDeepTurnFinalizeRuntimeAdapter"); +const assistantMcpDiscoveryResponsePolicy_1 = require("./assistantMcpDiscoveryResponsePolicy"); function toNullableString(value) { if (typeof value !== "string") { return null; @@ -68,12 +69,15 @@ function normalizeAddressRuntimeMetaForDeep(value) { toolGateDecision: toNullableString(source.toolGateDecision), toolGateReason: toNullableString(source.toolGateReason), predecomposeContract: source.predecomposeContract ?? null, - orchestrationContract: source.orchestrationContract ?? null + semanticExtractionContract: source.semanticExtractionContract ?? null, + orchestrationContract: source.orchestrationContract ?? null, + mcpDiscoveryRuntimeEntryPoint: source.mcpDiscoveryRuntimeEntryPoint ?? null }; } function runAssistantDeepTurnResponseRuntime(input) { const runPackagingRuntimeSafe = input.runPackagingRuntime ?? assistantDeepTurnPackagingRuntimeAdapter_1.runAssistantDeepTurnPackagingRuntime; const runFinalizeDeepTurnSafe = input.runFinalizeDeepTurn ?? assistantDeepTurnFinalizeRuntimeAdapter_1.finalizeAssistantDeepTurn; + const addressRuntimeMetaForDeep = normalizeAddressRuntimeMetaForDeep(input.addressRuntimeMetaForDeep); const packagingRuntime = runPackagingRuntimeSafe({ featureInvestigationStateV1: input.featureInvestigationStateV1, sessionId: input.sessionId, @@ -108,7 +112,7 @@ function runAssistantDeepTurnResponseRuntime(input) { featureContractsV11: input.featureContractsV11, featureAnswerPolicyV11: input.featureAnswerPolicyV11, previousInvestigationState: input.previousInvestigationState ?? null, - addressRuntimeMetaForDeep: normalizeAddressRuntimeMetaForDeep(input.addressRuntimeMetaForDeep), + addressRuntimeMetaForDeep, extractDroppedIntentSegments: input.extractDroppedIntentSegments, buildDebugRoutes: input.buildDebugRoutes, extractExecutionState: input.extractExecutionState, @@ -116,12 +120,35 @@ function runAssistantDeepTurnResponseRuntime(input) { persistInvestigationState: input.persistInvestigationState, messageIdFactory: input.messageIdFactory }); + const mcpDiscoveryResponsePolicy = (0, assistantMcpDiscoveryResponsePolicy_1.applyAssistantMcpDiscoveryResponsePolicy)({ + currentReply: packagingRuntime.safeAssistantReply, + currentReplySource: "deep_analysis", + addressRuntimeMeta: addressRuntimeMetaForDeep + }); + const assistantReply = mcpDiscoveryResponsePolicy.applied + ? mcpDiscoveryResponsePolicy.reply_text + : packagingRuntime.safeAssistantReply; + const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type; + const debug = { + ...packagingRuntime.debug, + mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, + mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, + mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied + }; + const assistantItem = mcpDiscoveryResponsePolicy.applied + ? { + ...packagingRuntime.assistantItem, + text: assistantReply, + reply_type: replyType, + debug + } + : packagingRuntime.assistantItem; const finalization = runFinalizeDeepTurnSafe({ sessionId: input.sessionId, - assistantReply: packagingRuntime.safeAssistantReply, - replyType: input.composition.reply_type, - assistantItem: packagingRuntime.assistantItem, - debug: packagingRuntime.debug, + assistantReply, + replyType, + assistantItem, + debug, deepAnalysisLogDetails: packagingRuntime.deepAnalysisLogDetails, appendItem: input.appendItem, getSession: input.getSession, @@ -131,6 +158,6 @@ function runAssistantDeepTurnResponseRuntime(input) { }); return { response: finalization.response, - debug: packagingRuntime.debug + debug }; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 9d9275a..c60b423 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -36,6 +36,7 @@ function isInternalMechanicsLine(value) { text.includes("probe_coverage") || text.includes("explain_evidence_basis") || text.includes("pilot_only_executes") || + text.includes("pilot_") || text.includes("runtime_") || text.includes("planner_") || text.includes("catalog_")); @@ -58,7 +59,10 @@ function modeFor(pilot) { } return "checked_sources_only"; } -function headlineFor(mode) { +function headlineFor(mode, pilot) { + if (pilot.derived_value_flow && mode === "confirmed_with_bounded_inference") { + return "По данным 1С найдены строки денежных движений; сумму можно называть только в рамках проверенного периода и найденных строк."; + } if (mode === "confirmed_with_bounded_inference") { return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк."; } @@ -87,11 +91,17 @@ function nextStepFor(mode, pilot) { } function buildMustNotClaim(pilot) { const claims = [ - "Do not claim legal registration age unless a legal registration source is confirmed.", - "Do not present inferred activity duration as a formally confirmed legal fact.", "Do not expose MCP primitive names, query text, debug ids, or internal execution mechanics in the user answer.", "Do not claim rows were checked when mcp_execution_performed=false." ]; + if (pilot.pilot_scope === "counterparty_lifecycle_query_documents_v1") { + 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") { + claims.push("Do not claim full all-time turnover unless the checked period and coverage prove it."); + claims.push("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows."); + } if (pilot.evidence.confirmed_facts.length === 0) { claims.push("Do not claim a confirmed business fact when confirmed_facts is empty."); } @@ -108,6 +118,18 @@ function derivedActivityInferenceLine(pilot) { "Это вывод по данным 1С, а не юридически подтвержденный возраст регистрации." ].join(" "); } +function derivedValueFlowConfirmedLine(pilot) { + const flow = pilot.derived_value_flow; + if (!flow) { + return null; + } + const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : ""; + const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне"; + 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С, а не подтверждение полного оборота вне проверенного окна.`; +} function buildAssistantMcpDiscoveryAnswerDraft(pilot) { const mode = modeFor(pilot); const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes]; @@ -122,12 +144,16 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { const inferenceLines = derivedInferenceLine ? [derivedInferenceLine] : pilot.evidence.inferred_facts; + const derivedValueLine = derivedValueFlowConfirmedLine(pilot); + const confirmedLines = derivedValueLine + ? [...pilot.evidence.confirmed_facts, derivedValueLine] + : pilot.evidence.confirmed_facts; return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryAnswerAdapter", answer_mode: mode, - headline: headlineFor(mode), - confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts), + headline: headlineFor(mode, pilot), + confirmed_lines: uniqueStrings(confirmedLines), inference_lines: uniqueStrings(inferenceLines), unknown_lines: uniqueStrings(pilot.evidence.unknown_facts), limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]), diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index cd31d20..e39f9e6 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -81,6 +81,19 @@ function buildLifecycleFilters(planner) { sort: "period_asc" }; } +function buildValueFlowFilters(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const counterparty = firstEntityCandidate(planner); + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + return { + ...dateScopeToFilters(dateScope), + ...(counterparty ? { counterparty } : {}), + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} function isLifecyclePilotEligible(planner) { const meaning = planner.discovery_plan.turn_meaning_ref; const domain = String(meaning?.asked_domain_family ?? "").toLowerCase(); @@ -89,6 +102,19 @@ function isLifecyclePilotEligible(planner) { return (planner.proposed_primitives.includes("query_documents") && (combined.includes("lifecycle") || combined.includes("activity") || combined.includes("duration") || combined.includes("age"))); } +function isValueFlowPilotEligible(planner) { + const meaning = planner.discovery_plan.turn_meaning_ref; + const domain = String(meaning?.asked_domain_family ?? "").toLowerCase(); + const action = String(meaning?.asked_action_family ?? "").toLowerCase(); + const unsupported = String(meaning?.unsupported_but_understood_family ?? "").toLowerCase(); + const combined = `${domain} ${action} ${unsupported}`; + return (planner.proposed_primitives.includes("query_movements") && + (combined.includes("turnover") || + combined.includes("revenue") || + combined.includes("payment") || + combined.includes("payout") || + combined.includes("value"))); +} function skippedProbeResult(step, limitation) { return { primitive_id: step.primitive_id, @@ -107,7 +133,7 @@ function queryResultToProbeResult(primitiveId, result) { limitation: result.error }; } -function summarizeRows(result) { +function summarizeLifecycleRows(result) { if (result.error) { return null; } @@ -116,6 +142,15 @@ function summarizeRows(result) { } return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`; } +function summarizeValueFlowRows(result) { + if (result.error) { + return null; + } + if (result.fetched_rows <= 0) { + return "0 MCP value-flow rows fetched"; + } + return `${result.fetched_rows} MCP value-flow rows fetched, ${result.matched_rows} matched value-flow scope`; +} function rowDateValue(row) { const candidates = [ row["Период"], @@ -134,6 +169,37 @@ function rowDateValue(row) { } return null; } +function rowAmountValue(row) { + const candidates = [ + row["Сумма"], + row["РЎСѓРјРјР°"], + row["СуммаДокумента"], + row["СуммаДокумента"], + row["Amount"], + row["amount"], + row["Total"], + row["total"] + ]; + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + const text = toNonEmptyString(candidate); + if (!text) { + continue; + } + const normalized = text + .replace(/\s+/g, "") + .replace(/\u00a0/g, "") + .replace(",", ".") + .replace(/[^\d.-]/g, ""); + const parsed = Number(normalized); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} function monthDiff(firstIsoDate, latestIsoDate) { const first = new Date(`${firstIsoDate}T00:00:00.000Z`); const latest = new Date(`${latestIsoDate}T00:00:00.000Z`); @@ -184,7 +250,48 @@ function deriveActivityPeriod(result) { inference_basis: "first_and_latest_confirmed_1c_activity_rows" }; } -function buildConfirmedFacts(result, counterparty) { +function formatAmountHumanRu(amount) { + const formatted = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2 + }) + .format(amount) + .replace(/\u00a0/g, " "); + return `${formatted} руб.`; +} +function deriveValueFlow(result, counterparty, periodScope) { + if (!result || result.error || result.matched_rows <= 0) { + return null; + } + let totalAmount = 0; + let rowsWithAmount = 0; + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount !== null) { + totalAmount += amount; + rowsWithAmount += 1; + } + } + if (rowsWithAmount <= 0) { + return null; + } + const dates = result.rows + .map((row) => rowDateValue(row)) + .filter((value) => Boolean(value)) + .sort(); + return { + counterparty, + period_scope: periodScope, + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + total_amount: totalAmount, + total_amount_human_ru: formatAmountHumanRu(totalAmount), + first_movement_date: dates[0] ?? null, + latest_movement_date: dates[dates.length - 1] ?? null, + inference_basis: "sum_of_confirmed_1c_value_flow_rows" + }; +} +function buildLifecycleConfirmedFacts(result, counterparty) { if (result.error || result.matched_rows <= 0) { return []; } @@ -194,15 +301,38 @@ function buildConfirmedFacts(result, counterparty) { : "1C activity rows were found for the requested counterparty scope" ]; } -function buildInferredFacts(result) { +function buildValueFlowConfirmedFacts(result, counterparty) { + if (result.error || result.matched_rows <= 0) { + return []; + } + return [ + counterparty + ? `1C value-flow rows were found for counterparty ${counterparty}` + : "1C value-flow rows were found for the requested counterparty scope" + ]; +} +function buildLifecycleInferredFacts(result) { if (result.error || result.fetched_rows <= 0) { return []; } return ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"]; } -function buildUnknownFacts() { +function buildValueFlowInferredFacts(derived) { + if (!derived) { + return []; + } + 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 buildEmptyEvidence(planner, dryRun, probeResults, reason) { return (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, @@ -235,6 +365,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["MCP discovery pilot was blocked before execution"], reason_codes: reasonCodes }; @@ -255,11 +386,14 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["MCP discovery pilot needs more scope before execution"], reason_codes: reasonCodes }; } - if (!isLifecyclePilotEligible(planner)) { + const lifecyclePilotEligible = isLifecyclePilotEligible(planner); + const valueFlowPilotEligible = isValueFlowPilotEligible(planner); + if (!lifecyclePilotEligible && !valueFlowPilotEligible) { pushReason(reasonCodes, "pilot_scope_unsupported_for_live_execution"); for (const step of dryRun.execution_steps) { skippedPrimitives.push(step.primitive_id); @@ -279,12 +413,94 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["MCP discovery pilot scope is not implemented yet"], reason_codes: reasonCodes }; } - let queryResult = null; const counterparty = firstEntityCandidate(planner); + const dateScope = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_date_scope); + if (valueFlowPilotEligible) { + let queryResult = null; + const filters = buildValueFlowFilters(planner); + const selection = (0, addressRecipeCatalog_1.selectAddressRecipe)("customer_revenue_and_payments", 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"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "counterparty_value_flow_query_movements_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + derived_activity_period: null, + derived_value_flow: null, + query_limitations: ["Value-flow recipe is not available"], + reason_codes: reasonCodes + }; + } + const recipePlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(selection.selected_recipe, filters); + for (const step of dryRun.execution_steps) { + if (step.primitive_id !== "query_movements") { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_value_flow_uses_query_movements_and_derives_aggregate")); + continue; + } + queryResult = await deps.executeAddressMcpQuery({ + query: recipePlan.query, + limit: recipePlan.limit, + account_scope: recipePlan.account_scope + }); + executedPrimitives.push(step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); + if (queryResult.error) { + pushUnique(queryLimitations, queryResult.error); + pushReason(reasonCodes, "pilot_query_movements_mcp_error"); + } + else { + pushReason(reasonCodes, "pilot_query_movements_mcp_executed"); + } + } + const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null; + const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope); + 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) : [], + inferredFacts: buildValueFlowInferredFacts(derivedValueFlow), + unknownFacts: buildValueFlowUnknownFacts(dateScope), + sourceRowsSummary, + queryLimitations, + recommendedNextProbe: "explain_evidence_basis" + }); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "executed", + pilot_scope: "counterparty_value_flow_query_movements_v1", + dry_run: dryRun, + mcp_execution_performed: executedPrimitives.length > 0, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: sourceRowsSummary, + derived_activity_period: null, + derived_value_flow: derivedValueFlow, + query_limitations: queryLimitations, + reason_codes: reasonCodes + }; + } + let queryResult = null; const filters = buildLifecycleFilters(planner); const selection = (0, addressRecipeCatalog_1.selectAddressRecipe)("counterparty_activity_lifecycle", filters); if (!selection.selected_recipe) { @@ -303,6 +519,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["Lifecycle recipe is not available"], reason_codes: reasonCodes }; @@ -329,7 +546,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { pushReason(reasonCodes, "pilot_query_documents_mcp_executed"); } } - const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null; + const sourceRowsSummary = queryResult ? summarizeLifecycleRows(queryResult) : null; const derivedActivityPeriod = deriveActivityPeriod(queryResult); if (derivedActivityPeriod) { pushReason(reasonCodes, "pilot_derived_activity_period_from_confirmed_rows"); @@ -337,9 +554,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildConfirmedFacts(queryResult, counterparty) : [], - inferredFacts: queryResult ? buildInferredFacts(queryResult) : [], - unknownFacts: buildUnknownFacts(), + confirmedFacts: queryResult ? buildLifecycleConfirmedFacts(queryResult, counterparty) : [], + inferredFacts: queryResult ? buildLifecycleInferredFacts(queryResult) : [], + unknownFacts: buildLifecycleUnknownFacts(), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" @@ -357,6 +574,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { evidence, source_rows_summary: sourceRowsSummary, derived_activity_period: derivedActivityPeriod, + derived_value_flow: null, query_limitations: queryLimitations, reason_codes: reasonCodes }; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index fae3e00..477ee33 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -51,6 +51,7 @@ function hasInternalMechanics(value) { return (text.includes("query_documents") || text.includes("query_movements") || text.includes("primitive") || + text.includes("pilot_") || text.includes("runtime_") || text.includes("planner_") || text.includes("catalog_") || @@ -67,12 +68,28 @@ function localizeLine(value) { if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки активности по запрошенному контрагентскому контуру."; } + const valueFlowMatch = value.match(/^1C value-flow rows were found for counterparty\s+(.+)$/i); + if (valueFlowMatch) { + return `В 1С найдены строки денежных движений по контрагенту ${valueFlowMatch[1]}.`; + } + if (/^1C value-flow 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 (/^Legal registration date is not proven by this MCP discovery pilot$/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 "Полный оборот за все время без явно проверенного периода не подтвержден."; + } 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 a99f1f9..eea87d0 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -41,6 +41,7 @@ function hasInternalMechanics(value) { return (text.includes("query_documents") || text.includes("query_movements") || text.includes("primitive") || + text.includes("pilot_") || text.includes("runtime_") || text.includes("planner_") || text.includes("catalog_") || @@ -72,6 +73,14 @@ function isDiscoveryReadyChatCandidate(input, entryPoint) { turnInput?.should_run_discovery === true && (input.livingChatSource === "llm_chat" || input.currentReplySource === "llm_chat")); } +function isDiscoveryReadyDeepCandidate(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 === "deep_analysis" || source === "partial_coverage" || source === "normalizer_v2_0_2")); +} function applyAssistantMcpDiscoveryResponsePolicy(input) { const currentReply = String(input.currentReply ?? ""); const currentReplySource = toNonEmptyString(input.currentReplySource) ?? toNonEmptyString(input.livingChatSource) ?? "unknown"; @@ -80,6 +89,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const reasonCodes = [...candidate.reason_codes]; const unsupportedBoundary = isUnsupportedCurrentTurnBoundary(input); const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); + const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); } @@ -89,6 +99,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (!discoveryReadyChatCandidate) { pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_chat_candidate"); } + if (!discoveryReadyDeepCandidate) { + pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_deep_candidate"); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -102,7 +115,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_contains_internal_mechanics"); } const canApply = Boolean(entryPoint) && - (unsupportedBoundary || discoveryReadyChatCandidate) && + (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate) && 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 5e6ddaa..581a8c5 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -95,12 +95,15 @@ function collectDateScope(predecompose) { 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); +} function semanticNeedFor(input) { const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); if (input.lifecycleSignal || /(?:lifecycle|activity|duration|age)/iu.test(combined)) { return "counterparty lifecycle evidence"; } - if (/(?:turnover|revenue|payment|payout|value)/iu.test(combined)) { + if (input.valueFlowSignal || /(?:turnover|revenue|payment|payout|value)/iu.test(combined)) { return "counterparty value-flow evidence"; } if (/(?:document|documents|list_documents)/iu.test(combined)) { @@ -115,6 +118,9 @@ function shouldRunDiscovery(input) { if (input.lifecycleSignal || input.unsupported) { return true; } + if (input.valueFlowSignal && !input.explicitIntentCandidate) { + return true; + } if (!input.explicitIntentCandidate && input.semanticDataNeed) { return true; } @@ -127,6 +133,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const reasonCodes = []; const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); const lifecycleSignal = hasLifecycleSignal(rawText); + const valueFlowSignal = !lifecycleSignal && hasValueFlowSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); @@ -135,18 +142,23 @@ function buildAssistantMcpDiscoveryTurnInput(input) { domain: rawDomain, action: rawAction, unsupported, - lifecycleSignal + lifecycleSignal, + valueFlowSignal }); const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); pushUnique(entityCandidates, predecomposeEntities.counterparty); + if (valueFlowSignal && !predecomposeEntities.counterparty) { + pushUnique(entityCandidates, predecomposeEntities.organization); + } + const explicitOrganizationScope = valueFlowSignal && !predecomposeEntities.counterparty ? null : predecomposeEntities.organization; const turnMeaning = { - asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : rawDomain, - asked_action_family: lifecycleSignal ? "activity_duration" : rawAction, + asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" : rawDomain, + asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal ? "turnover" : rawAction, explicit_entity_candidates: entityCandidates, - explicit_organization_scope: predecomposeEntities.organization, + explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: collectDateScope(predecomposeContract), - unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : null), - stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal) + unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value_or_turnover" : null), + stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal || valueFlowSignal) }; const cleanTurnMeaning = {}; if (toNonEmptyString(turnMeaning.asked_domain_family)) { @@ -173,6 +185,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const runDiscovery = shouldRunDiscovery({ unsupported, lifecycleSignal, + valueFlowSignal, semanticDataNeed, explicitIntentCandidate }); @@ -183,10 +196,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "predecompose_contract" : lifecycleSignal ? "raw_text" - : "none"; + : valueFlowSignal + ? "raw_text" + : "none"; if (lifecycleSignal) { pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected"); } + if (valueFlowSignal) { + pushReason(reasonCodes, "mcp_discovery_value_flow_signal_detected"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts index 9982cce..3b55bc9 100644 --- a/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantDeepTurnResponseRuntimeAdapter.ts @@ -20,6 +20,7 @@ import { finalizeAssistantDeepTurn, type FinalizeAssistantDeepTurnInput } from "./assistantDeepTurnFinalizeRuntimeAdapter"; +import { applyAssistantMcpDiscoveryResponsePolicy } from "./assistantMcpDiscoveryResponsePolicy"; import type { ClaimBoundAnchorAudit, TargetedEvidenceAcquisitionAudit } from "./assistantClaimBoundEvidence"; import type { CompanyAnchorSet } from "./companyAnchorResolver"; import type { @@ -181,7 +182,9 @@ function normalizeAddressRuntimeMetaForDeep( toolGateDecision: toNullableString(source.toolGateDecision), toolGateReason: toNullableString(source.toolGateReason), predecomposeContract: source.predecomposeContract ?? null, - orchestrationContract: source.orchestrationContract ?? null + semanticExtractionContract: source.semanticExtractionContract ?? null, + orchestrationContract: source.orchestrationContract ?? null, + mcpDiscoveryRuntimeEntryPoint: source.mcpDiscoveryRuntimeEntryPoint ?? null }; } @@ -190,6 +193,7 @@ export function runAssistantDeepTurnResponseRuntime( ): RunAssistantDeepTurnResponseRuntimeOutput { const runPackagingRuntimeSafe = input.runPackagingRuntime ?? runAssistantDeepTurnPackagingRuntime; const runFinalizeDeepTurnSafe = input.runFinalizeDeepTurn ?? finalizeAssistantDeepTurn; + const addressRuntimeMetaForDeep = normalizeAddressRuntimeMetaForDeep(input.addressRuntimeMetaForDeep); const packagingRuntime = runPackagingRuntimeSafe({ featureInvestigationStateV1: input.featureInvestigationStateV1, @@ -225,7 +229,7 @@ export function runAssistantDeepTurnResponseRuntime( featureContractsV11: input.featureContractsV11, featureAnswerPolicyV11: input.featureAnswerPolicyV11, previousInvestigationState: input.previousInvestigationState ?? null, - addressRuntimeMetaForDeep: normalizeAddressRuntimeMetaForDeep(input.addressRuntimeMetaForDeep), + addressRuntimeMetaForDeep, extractDroppedIntentSegments: input.extractDroppedIntentSegments, buildDebugRoutes: input.buildDebugRoutes, extractExecutionState: input.extractExecutionState, @@ -234,12 +238,36 @@ export function runAssistantDeepTurnResponseRuntime( messageIdFactory: input.messageIdFactory }); + const mcpDiscoveryResponsePolicy = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: packagingRuntime.safeAssistantReply, + currentReplySource: "deep_analysis", + addressRuntimeMeta: addressRuntimeMetaForDeep as unknown as Record | null + }); + const assistantReply = mcpDiscoveryResponsePolicy.applied + ? mcpDiscoveryResponsePolicy.reply_text + : packagingRuntime.safeAssistantReply; + const replyType = mcpDiscoveryResponsePolicy.applied ? "partial_coverage" : input.composition.reply_type; + const debug = { + ...packagingRuntime.debug, + mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, + mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, + mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied + } as AssistantDebugPayload; + const assistantItem = mcpDiscoveryResponsePolicy.applied + ? { + ...packagingRuntime.assistantItem, + text: assistantReply, + reply_type: replyType, + debug + } + : packagingRuntime.assistantItem; + const finalization = runFinalizeDeepTurnSafe({ sessionId: input.sessionId, - assistantReply: packagingRuntime.safeAssistantReply, - replyType: input.composition.reply_type, - assistantItem: packagingRuntime.assistantItem, - debug: packagingRuntime.debug, + assistantReply, + replyType, + assistantItem, + debug, deepAnalysisLogDetails: packagingRuntime.deepAnalysisLogDetails, appendItem: input.appendItem, getSession: input.getSession, @@ -250,6 +278,6 @@ export function runAssistantDeepTurnResponseRuntime( return { response: finalization.response, - debug: packagingRuntime.debug + debug }; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 5f08340..af2e459 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -62,6 +62,7 @@ function isInternalMechanicsLine(value: string): boolean { text.includes("probe_coverage") || text.includes("explain_evidence_basis") || text.includes("pilot_only_executes") || + text.includes("pilot_") || text.includes("runtime_") || text.includes("planner_") || text.includes("catalog_") @@ -88,7 +89,10 @@ function modeFor(pilot: AssistantMcpDiscoveryPilotExecutionContract): AssistantM return "checked_sources_only"; } -function headlineFor(mode: AssistantMcpDiscoveryAnswerMode): string { +function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string { + if (pilot.derived_value_flow && mode === "confirmed_with_bounded_inference") { + return "По данным 1С найдены строки денежных движений; сумму можно называть только в рамках проверенного периода и найденных строк."; + } if (mode === "confirmed_with_bounded_inference") { return "По данным 1С есть подтвержденная активность; длительность можно оценивать только как вывод из этих строк."; } @@ -119,11 +123,17 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] { const claims = [ - "Do not claim legal registration age unless a legal registration source is confirmed.", - "Do not present inferred activity duration as a formally confirmed legal fact.", "Do not expose MCP primitive names, query text, debug ids, or internal execution mechanics in the user answer.", "Do not claim rows were checked when mcp_execution_performed=false." ]; + if (pilot.pilot_scope === "counterparty_lifecycle_query_documents_v1") { + 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") { + claims.push("Do not claim full all-time turnover unless the checked period and coverage prove it."); + claims.push("Do not present a derived sum as a legal/accounting final total outside the checked 1C rows."); + } if (pilot.evidence.confirmed_facts.length === 0) { claims.push("Do not claim a confirmed business fact when confirmed_facts is empty."); } @@ -142,6 +152,20 @@ function derivedActivityInferenceLine(pilot: AssistantMcpDiscoveryPilotExecution ].join(" "); } +function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + const flow = pilot.derived_value_flow; + if (!flow) { + return null; + } + const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : ""; + const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне"; + 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С, а не подтверждение полного оборота вне проверенного окна.`; +} + export function buildAssistantMcpDiscoveryAnswerDraft( pilot: AssistantMcpDiscoveryPilotExecutionContract ): AssistantMcpDiscoveryAnswerDraftContract { @@ -158,13 +182,17 @@ export function buildAssistantMcpDiscoveryAnswerDraft( const inferenceLines = derivedInferenceLine ? [derivedInferenceLine] : pilot.evidence.inferred_facts; + const derivedValueLine = derivedValueFlowConfirmedLine(pilot); + const confirmedLines = derivedValueLine + ? [...pilot.evidence.confirmed_facts, derivedValueLine] + : pilot.evidence.confirmed_facts; return { schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryAnswerAdapter", answer_mode: mode, - headline: headlineFor(mode), - confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts), + headline: headlineFor(mode, pilot), + confirmed_lines: uniqueStrings(confirmedLines), inference_lines: uniqueStrings(inferenceLines), unknown_lines: uniqueStrings(pilot.evidence.unknown_facts), limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]), diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index fce3159..99b9c98 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -40,11 +40,27 @@ export interface AssistantMcpDiscoveryDerivedActivityPeriod { inference_basis: "first_and_latest_confirmed_1c_activity_rows"; } +export interface AssistantMcpDiscoveryDerivedValueFlow { + counterparty: string | null; + period_scope: string | null; + rows_matched: number; + rows_with_amount: number; + total_amount: number; + total_amount_human_ru: string; + first_movement_date: string | null; + latest_movement_date: string | null; + inference_basis: "sum_of_confirmed_1c_value_flow_rows"; +} + +export type AssistantMcpDiscoveryPilotScope = + | "counterparty_lifecycle_query_documents_v1" + | "counterparty_value_flow_query_movements_v1"; + export interface AssistantMcpDiscoveryPilotExecutionContract { schema_version: typeof ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION; policy_owner: "assistantMcpDiscoveryPilotExecutor"; pilot_status: AssistantMcpDiscoveryPilotStatus; - pilot_scope: "counterparty_lifecycle_query_documents_v1"; + pilot_scope: AssistantMcpDiscoveryPilotScope; dry_run: AssistantMcpDiscoveryRuntimeDryRunContract; mcp_execution_performed: boolean; executed_primitives: string[]; @@ -53,6 +69,7 @@ export interface AssistantMcpDiscoveryPilotExecutionContract { evidence: AssistantMcpDiscoveryEvidenceContract; source_rows_summary: string | null; derived_activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null; + derived_value_flow: AssistantMcpDiscoveryDerivedValueFlow | null; query_limitations: string[]; reason_codes: string[]; } @@ -141,6 +158,20 @@ function buildLifecycleFilters(planner: AssistantMcpDiscoveryPlannerContract): A }; } +function buildValueFlowFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet { + const meaning = planner.discovery_plan.turn_meaning_ref; + const counterparty = firstEntityCandidate(planner); + const organization = toNonEmptyString(meaning?.explicit_organization_scope); + const dateScope = toNonEmptyString(meaning?.explicit_date_scope); + return { + ...dateScopeToFilters(dateScope), + ...(counterparty ? { counterparty } : {}), + ...(organization ? { organization } : {}), + limit: planner.discovery_plan.execution_budget.max_rows_per_probe, + sort: "period_asc" + }; +} + function isLifecyclePilotEligible(planner: AssistantMcpDiscoveryPlannerContract): boolean { const meaning = planner.discovery_plan.turn_meaning_ref; const domain = String(meaning?.asked_domain_family ?? "").toLowerCase(); @@ -152,6 +183,22 @@ function isLifecyclePilotEligible(planner: AssistantMcpDiscoveryPlannerContract) ); } +function isValueFlowPilotEligible(planner: AssistantMcpDiscoveryPlannerContract): boolean { + const meaning = planner.discovery_plan.turn_meaning_ref; + const domain = String(meaning?.asked_domain_family ?? "").toLowerCase(); + const action = String(meaning?.asked_action_family ?? "").toLowerCase(); + const unsupported = String(meaning?.unsupported_but_understood_family ?? "").toLowerCase(); + const combined = `${domain} ${action} ${unsupported}`; + return ( + planner.proposed_primitives.includes("query_movements") && + (combined.includes("turnover") || + combined.includes("revenue") || + combined.includes("payment") || + combined.includes("payout") || + combined.includes("value")) + ); +} + function skippedProbeResult(step: AssistantMcpDiscoveryRuntimeStepContract, limitation: string): AssistantMcpDiscoveryProbeResult { return { primitive_id: step.primitive_id, @@ -175,7 +222,7 @@ function queryResultToProbeResult( }; } -function summarizeRows(result: AddressMcpQueryExecutorResult): string | null { +function summarizeLifecycleRows(result: AddressMcpQueryExecutorResult): string | null { if (result.error) { return null; } @@ -185,6 +232,16 @@ function summarizeRows(result: AddressMcpQueryExecutorResult): string | null { return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`; } +function summarizeValueFlowRows(result: AddressMcpQueryExecutorResult): string | null { + if (result.error) { + return null; + } + if (result.fetched_rows <= 0) { + return "0 MCP value-flow rows fetched"; + } + return `${result.fetched_rows} MCP value-flow rows fetched, ${result.matched_rows} matched value-flow scope`; +} + function rowDateValue(row: Record): string | null { const candidates = [ row["Период"], @@ -204,6 +261,38 @@ function rowDateValue(row: Record): string | null { return null; } +function rowAmountValue(row: Record): number | null { + const candidates = [ + row["Сумма"], + row["РЎСѓРјРјР°"], + row["СуммаДокумента"], + row["СуммаДокумента"], + row["Amount"], + row["amount"], + row["Total"], + row["total"] + ]; + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + const text = toNonEmptyString(candidate); + if (!text) { + continue; + } + const normalized = text + .replace(/\s+/g, "") + .replace(/\u00a0/g, "") + .replace(",", ".") + .replace(/[^\d.-]/g, ""); + const parsed = Number(normalized); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + 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`); @@ -259,7 +348,54 @@ function deriveActivityPeriod( }; } -function buildConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { +function formatAmountHumanRu(amount: number): string { + const formatted = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2 + }) + .format(amount) + .replace(/\u00a0/g, " "); + return `${formatted} руб.`; +} + +function deriveValueFlow( + result: AddressMcpQueryExecutorResult | null, + counterparty: string | null, + periodScope: string | null +): AssistantMcpDiscoveryDerivedValueFlow | null { + if (!result || result.error || result.matched_rows <= 0) { + return null; + } + let totalAmount = 0; + let rowsWithAmount = 0; + for (const row of result.rows) { + const amount = rowAmountValue(row); + if (amount !== null) { + totalAmount += amount; + rowsWithAmount += 1; + } + } + if (rowsWithAmount <= 0) { + return null; + } + const dates = result.rows + .map((row) => rowDateValue(row)) + .filter((value): value is string => Boolean(value)) + .sort(); + return { + counterparty, + period_scope: periodScope, + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + total_amount: totalAmount, + total_amount_human_ru: formatAmountHumanRu(totalAmount), + first_movement_date: dates[0] ?? null, + latest_movement_date: dates[dates.length - 1] ?? null, + inference_basis: "sum_of_confirmed_1c_value_flow_rows" + }; +} + +function buildLifecycleConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { if (result.error || result.matched_rows <= 0) { return []; } @@ -270,17 +406,43 @@ function buildConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty ]; } -function buildInferredFacts(result: AddressMcpQueryExecutorResult): string[] { +function buildValueFlowConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { + if (result.error || result.matched_rows <= 0) { + return []; + } + return [ + counterparty + ? `1C value-flow rows were found for counterparty ${counterparty}` + : "1C value-flow rows were found for the requested counterparty scope" + ]; +} + +function buildLifecycleInferredFacts(result: AddressMcpQueryExecutorResult): string[] { if (result.error || result.fetched_rows <= 0) { return []; } return ["Business activity duration may be inferred from first and latest confirmed 1C activity rows"]; } -function buildUnknownFacts(): string[] { +function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueFlow | null): string[] { + if (!derived) { + return []; + } + return ["Counterparty value-flow total was calculated from confirmed 1C movement rows"]; +} + +function buildLifecycleUnknownFacts(): string[] { return ["Legal registration date is not proven by this MCP discovery pilot"]; } +function buildValueFlowUnknownFacts(periodScope: string | null): string[] { + 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 buildEmptyEvidence( planner: AssistantMcpDiscoveryPlannerContract, dryRun: AssistantMcpDiscoveryRuntimeDryRunContract, @@ -323,6 +485,7 @@ export async function executeAssistantMcpDiscoveryPilot( evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["MCP discovery pilot was blocked before execution"], reason_codes: reasonCodes }; @@ -344,12 +507,16 @@ export async function executeAssistantMcpDiscoveryPilot( evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["MCP discovery pilot needs more scope before execution"], reason_codes: reasonCodes }; } - if (!isLifecyclePilotEligible(planner)) { + const lifecyclePilotEligible = isLifecyclePilotEligible(planner); + const valueFlowPilotEligible = isValueFlowPilotEligible(planner); + + if (!lifecyclePilotEligible && !valueFlowPilotEligible) { pushReason(reasonCodes, "pilot_scope_unsupported_for_live_execution"); for (const step of dryRun.execution_steps) { skippedPrimitives.push(step.primitive_id); @@ -369,13 +536,99 @@ export async function executeAssistantMcpDiscoveryPilot( evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["MCP discovery pilot scope is not implemented yet"], reason_codes: reasonCodes }; } - let queryResult: AddressMcpQueryExecutorResult | null = null; const counterparty = firstEntityCandidate(planner); + const dateScope = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.explicit_date_scope); + + if (valueFlowPilotEligible) { + let queryResult: AddressMcpQueryExecutorResult | null = null; + const filters = buildValueFlowFilters(planner); + const selection = selectAddressRecipe("customer_revenue_and_payments", 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"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "unsupported", + pilot_scope: "counterparty_value_flow_query_movements_v1", + dry_run: dryRun, + mcp_execution_performed: false, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: null, + derived_activity_period: null, + derived_value_flow: null, + query_limitations: ["Value-flow recipe is not available"], + reason_codes: reasonCodes + }; + } + + const recipePlan = buildAddressRecipePlan(selection.selected_recipe, filters); + for (const step of dryRun.execution_steps) { + if (step.primitive_id !== "query_movements") { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_value_flow_uses_query_movements_and_derives_aggregate")); + continue; + } + queryResult = await deps.executeAddressMcpQuery({ + query: recipePlan.query, + limit: recipePlan.limit, + account_scope: recipePlan.account_scope + }); + executedPrimitives.push(step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); + if (queryResult.error) { + pushUnique(queryLimitations, queryResult.error); + pushReason(reasonCodes, "pilot_query_movements_mcp_error"); + } else { + pushReason(reasonCodes, "pilot_query_movements_mcp_executed"); + } + } + + const sourceRowsSummary = queryResult ? summarizeValueFlowRows(queryResult) : null; + const derivedValueFlow = deriveValueFlow(queryResult, counterparty, dateScope); + if (derivedValueFlow) { + pushReason(reasonCodes, "pilot_derived_value_flow_from_confirmed_rows"); + } + const evidence = resolveAssistantMcpDiscoveryEvidence({ + plan: planner.discovery_plan, + probeResults, + confirmedFacts: queryResult ? buildValueFlowConfirmedFacts(queryResult, counterparty) : [], + inferredFacts: buildValueFlowInferredFacts(derivedValueFlow), + unknownFacts: buildValueFlowUnknownFacts(dateScope), + sourceRowsSummary, + queryLimitations, + recommendedNextProbe: "explain_evidence_basis" + }); + + return { + schema_version: ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryPilotExecutor", + pilot_status: "executed", + pilot_scope: "counterparty_value_flow_query_movements_v1", + dry_run: dryRun, + mcp_execution_performed: executedPrimitives.length > 0, + executed_primitives: executedPrimitives, + skipped_primitives: skippedPrimitives, + probe_results: probeResults, + evidence, + source_rows_summary: sourceRowsSummary, + derived_activity_period: null, + derived_value_flow: derivedValueFlow, + query_limitations: queryLimitations, + reason_codes: reasonCodes + }; + } + + let queryResult: AddressMcpQueryExecutorResult | null = null; const filters = buildLifecycleFilters(planner); const selection = selectAddressRecipe("counterparty_activity_lifecycle", filters); if (!selection.selected_recipe) { @@ -394,6 +647,7 @@ export async function executeAssistantMcpDiscoveryPilot( evidence, source_rows_summary: null, derived_activity_period: null, + derived_value_flow: null, query_limitations: ["Lifecycle recipe is not available"], reason_codes: reasonCodes }; @@ -421,7 +675,7 @@ export async function executeAssistantMcpDiscoveryPilot( } } - const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null; + const sourceRowsSummary = queryResult ? summarizeLifecycleRows(queryResult) : null; const derivedActivityPeriod = deriveActivityPeriod(queryResult); if (derivedActivityPeriod) { pushReason(reasonCodes, "pilot_derived_activity_period_from_confirmed_rows"); @@ -429,9 +683,9 @@ export async function executeAssistantMcpDiscoveryPilot( const evidence = resolveAssistantMcpDiscoveryEvidence({ plan: planner.discovery_plan, probeResults, - confirmedFacts: queryResult ? buildConfirmedFacts(queryResult, counterparty) : [], - inferredFacts: queryResult ? buildInferredFacts(queryResult) : [], - unknownFacts: buildUnknownFacts(), + confirmedFacts: queryResult ? buildLifecycleConfirmedFacts(queryResult, counterparty) : [], + inferredFacts: queryResult ? buildLifecycleInferredFacts(queryResult) : [], + unknownFacts: buildLifecycleUnknownFacts(), sourceRowsSummary, queryLimitations, recommendedNextProbe: "explain_evidence_basis" @@ -450,6 +704,7 @@ export async function executeAssistantMcpDiscoveryPilot( evidence, source_rows_summary: sourceRowsSummary, derived_activity_period: derivedActivityPeriod, + derived_value_flow: null, query_limitations: queryLimitations, reason_codes: reasonCodes }; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 4e3ebc9..ddcd197 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -78,6 +78,7 @@ function hasInternalMechanics(value: string): boolean { text.includes("query_documents") || text.includes("query_movements") || text.includes("primitive") || + text.includes("pilot_") || text.includes("runtime_") || text.includes("planner_") || text.includes("catalog_") || @@ -97,12 +98,28 @@ function localizeLine(value: string): string { if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки активности по запрошенному контрагентскому контуру."; } + const valueFlowMatch = value.match(/^1C value-flow rows were found for counterparty\s+(.+)$/i); + if (valueFlowMatch) { + return `В 1С найдены строки денежных движений по контрагенту ${valueFlowMatch[1]}.`; + } + if (/^1C value-flow 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 (/^Legal registration date is not proven by this MCP discovery pilot$/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 "Полный оборот за все время без явно проверенного периода не подтвержден."; + } return value; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 768e90b..414eace 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -73,6 +73,7 @@ function hasInternalMechanics(value: string): boolean { text.includes("query_documents") || text.includes("query_movements") || text.includes("primitive") || + text.includes("pilot_") || text.includes("runtime_") || text.includes("planner_") || text.includes("catalog_") || @@ -122,6 +123,20 @@ function isDiscoveryReadyChatCandidate( ); } +function isDiscoveryReadyDeepCandidate( + 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 === "deep_analysis" || source === "partial_coverage" || source === "normalizer_v2_0_2") + ); +} + export function applyAssistantMcpDiscoveryResponsePolicy( input: ApplyAssistantMcpDiscoveryResponsePolicyInput ): AssistantMcpDiscoveryResponsePolicyResult { @@ -133,6 +148,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const reasonCodes = [...candidate.reason_codes]; const unsupportedBoundary = isUnsupportedCurrentTurnBoundary(input); const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); + const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); @@ -143,6 +159,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (!discoveryReadyChatCandidate) { pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_chat_candidate"); } + if (!discoveryReadyDeepCandidate) { + pushReason(reasonCodes, "mcp_discovery_response_policy_not_discovery_ready_deep_candidate"); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -158,7 +177,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const canApply = Boolean(entryPoint) && - (unsupportedBoundary || discoveryReadyChatCandidate) && + (unsupportedBoundary || discoveryReadyChatCandidate || discoveryReadyDeepCandidate) && 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 b7a02d5..ce560fd 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -138,17 +138,24 @@ 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( + text + ); +} + function semanticNeedFor(input: { domain: string | null; action: string | null; unsupported: string | null; lifecycleSignal: boolean; + valueFlowSignal: boolean; }): string | null { const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); if (input.lifecycleSignal || /(?:lifecycle|activity|duration|age)/iu.test(combined)) { return "counterparty lifecycle evidence"; } - if (/(?:turnover|revenue|payment|payout|value)/iu.test(combined)) { + if (input.valueFlowSignal || /(?:turnover|revenue|payment|payout|value)/iu.test(combined)) { return "counterparty value-flow evidence"; } if (/(?:document|documents|list_documents)/iu.test(combined)) { @@ -163,12 +170,16 @@ function semanticNeedFor(input: { function shouldRunDiscovery(input: { unsupported: string | null; lifecycleSignal: boolean; + valueFlowSignal: boolean; semanticDataNeed: string | null; explicitIntentCandidate: string | null; }): boolean { if (input.lifecycleSignal || input.unsupported) { return true; } + if (input.valueFlowSignal && !input.explicitIntentCandidate) { + return true; + } if (!input.explicitIntentCandidate && input.semanticDataNeed) { return true; } @@ -184,6 +195,7 @@ export function buildAssistantMcpDiscoveryTurnInput( const reasonCodes: string[] = []; const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); const lifecycleSignal = hasLifecycleSignal(rawText); + const valueFlowSignal = !lifecycleSignal && hasValueFlowSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); @@ -193,19 +205,25 @@ export function buildAssistantMcpDiscoveryTurnInput( domain: rawDomain, action: rawAction, unsupported, - lifecycleSignal + lifecycleSignal, + valueFlowSignal }); const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); pushUnique(entityCandidates, predecomposeEntities.counterparty); + if (valueFlowSignal && !predecomposeEntities.counterparty) { + pushUnique(entityCandidates, predecomposeEntities.organization); + } + const explicitOrganizationScope = + valueFlowSignal && !predecomposeEntities.counterparty ? null : predecomposeEntities.organization; const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = { - asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : rawDomain, - asked_action_family: lifecycleSignal ? "activity_duration" : rawAction, + asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" : rawDomain, + asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal ? "turnover" : rawAction, explicit_entity_candidates: entityCandidates, - explicit_organization_scope: predecomposeEntities.organization, + explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: collectDateScope(predecomposeContract), - unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : null), - stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal) + unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value_or_turnover" : null), + stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal || valueFlowSignal) }; const cleanTurnMeaning: AssistantMcpDiscoveryTurnMeaningRef = {}; @@ -234,6 +252,7 @@ export function buildAssistantMcpDiscoveryTurnInput( const runDiscovery = shouldRunDiscovery({ unsupported, lifecycleSignal, + valueFlowSignal, semanticDataNeed, explicitIntentCandidate }); @@ -244,11 +263,16 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "predecompose_contract" : lifecycleSignal ? "raw_text" + : valueFlowSignal + ? "raw_text" : "none"; if (lifecycleSignal) { pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected"); } + if (valueFlowSignal) { + pushReason(reasonCodes, "mcp_discovery_value_flow_signal_detected"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts index 0f379dc..4209bc6 100644 --- a/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantDeepTurnResponseRuntimeAdapter.test.ts @@ -110,7 +110,10 @@ describe("assistant deep turn response runtime adapter", () => { }) ); expect(runtime.response).toEqual(responsePayload); - expect(runtime.debug).toEqual({ trace_id: "trace-1" }); + expect(runtime.debug).toMatchObject({ + trace_id: "trace-1", + mcp_discovery_response_applied: false + }); }); it("passes feature flags and followup flags into packaging stage", () => { @@ -160,4 +163,86 @@ describe("assistant deep turn response runtime adapter", () => { }) ); }); + + it("can replace a bad deep partial answer with a guarded MCP discovery candidate", () => { + const runPackagingRuntime = vi.fn(() => ({ + messageId: "msg-a1", + investigationStateSnapshot: null, + droppedIntentSegments: [], + analysisContextForContract: null, + routesForDebug: [], + resolvedExecutionState: [], + safeAssistantReplyBase: "bad-base", + safeAssistantReply: "bad deep partial answer", + debug: { trace_id: "trace-1" }, + assistantItem: { + message_id: "msg-a1", + session_id: "asst-1", + role: "assistant", + text: "bad deep partial answer", + reply_type: "partial_coverage", + created_at: "2026-04-10T00:00:00.000Z", + trace_id: "trace-1", + debug: null + }, + deepAnalysisLogDetails: {} + })); + const runFinalizeDeepTurn = vi.fn((input) => ({ + response: { + ok: true, + session_id: "asst-1", + assistant_reply: input.assistantReply, + reply_type: input.replyType, + conversation_item: input.assistantItem, + conversation: [], + debug: input.debug + } + })); + + const runtime = runAssistantDeepTurnResponseRuntime( + buildBaseInput({ + composition: { reply_type: "partial_coverage" }, + addressRuntimeMetaForDeep: { + 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: { 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 answer", + confirmed_lines: ["Confirmed fact"], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + }, + reason_codes: [] + } + }, + runPackagingRuntime, + runFinalizeDeepTurn + }) + ); + + expect(runtime.response.assistant_reply).toContain("Discovery answer"); + expect(runtime.debug?.mcp_discovery_response_applied).toBe(true); + expect(runFinalizeDeepTurn).toHaveBeenCalledWith( + expect.objectContaining({ + assistantReply: expect.stringContaining("Discovery answer"), + replyType: "partial_coverage", + assistantItem: expect.objectContaining({ + text: expect.stringContaining("Discovery answer") + }) + }) + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 934d86a..168c363 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -80,6 +80,36 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.must_not_claim).toContain("Do not claim rows were checked when mcp_execution_performed=false."); }); + it("turns value-flow evidence into a bounded turnover answer draft", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildDeps([ + { Period: "2020-01-15T00:00:00", Amount: 1250, Counterparty: "SVK" }, + { Period: "2020-02-20T00:00:00", Amount: "2 500,50", 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("3 750,50 руб."); + expect(confirmedText).toContain("2020-01-15"); + expect(confirmedText).toContain("2020-02-20"); + expect(draft.unknown_lines).toContain("Full turnover outside the checked period is not proven by this MCP discovery pilot"); + expect(draft.must_not_claim).toContain("Do not claim full all-time turnover unless the checked period and coverage prove it."); + expect(draft.limitation_lines.join("\n")).not.toContain("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 dd191bb..87592e2 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -76,6 +76,49 @@ describe("assistant MCP discovery pilot executor", () => { expect(deps.executeAddressMcpQuery).not.toHaveBeenCalled(); }); + it("executes value-flow query_movements and derives a guarded turnover sum", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020" + } + }); + const deps = buildDeps([ + { Period: "2020-01-15T00:00:00", Amount: 1250, Counterparty: "SVK" }, + { Period: "2020-02-20T00:00:00", Amount: "2 500,50", Counterparty: "SVK" } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.pilot_scope).toBe("counterparty_value_flow_query_movements_v1"); + expect(result.mcp_execution_performed).toBe(true); + expect(result.executed_primitives).toEqual(["query_movements"]); + expect(result.skipped_primitives).toEqual(["resolve_entity_reference", "aggregate_by_axis", "probe_coverage"]); + expect(result.evidence.evidence_status).toBe("confirmed"); + expect(result.evidence.confirmed_facts[0]).toContain("value-flow rows"); + expect(result.source_rows_summary).toBe("2 MCP value-flow rows fetched, 2 matched value-flow scope"); + expect(result.derived_value_flow).toMatchObject({ + counterparty: "SVK", + period_scope: "2020", + rows_matched: 2, + rows_with_amount: 2, + total_amount: 3750.5, + first_movement_date: "2020-01-15", + latest_movement_date: "2020-02-20", + inference_basis: "sum_of_confirmed_1c_value_flow_rows" + }); + expect(result.reason_codes).toContain("pilot_query_movements_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_value_flow_from_confirmed_rows"); + + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1); + const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0]; + expect(String(call?.query ?? "")).toContain("ПоступлениеНаРасчетныйСчет"); + expect(call?.limit).toBeGreaterThan(0); + }); + 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 03762b0..e78226b 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -44,6 +44,38 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("primitive"); }); + it("localizes value-flow 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 value-flow rows were found for counterparty SVK", + "По найденным строкам денежных движений в 1С по контрагенту SVK за период 2020 сумма составляет 3 750 руб." + ], + inference_lines: ["Counterparty value-flow total was calculated from confirmed 1C movement rows"], + unknown_lines: ["Full turnover 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("3 750 руб."); + 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 aef1b93..b3ac245 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -89,6 +89,26 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_chat_candidate"); }); + it("applies a guarded candidate for discovery-ready deep partial answers", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "wrong deep partial answer", + currentReplySource: "deep_analysis", + addressRuntimeMeta: { + mcpDiscoveryRuntimeEntryPoint: 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_deep_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/assistantMcpDiscoveryRuntimeEntryPoint.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts index c9698b4..761dba1 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts @@ -64,12 +64,17 @@ describe("assistant MCP discovery runtime entry point", () => { entities: { counterparty: "Группа СВК" }, period: { period_from: "2020-01-01", period_to: "2020-12-31" } }, - deps: buildDeps([]) + deps: buildDeps([ + { Period: "2020-01-15T00:00:00", Amount: 1250, Counterparty: "SVK" }, + { Period: "2020-02-20T00:00:00", Amount: 2500, Counterparty: "SVK" } + ]) }); expect(result.entry_status).toBe("bridge_executed"); expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toEqual(["SVK", "Группа СВК"]); - expect(result.bridge?.bridge_status).toBe("unsupported"); + expect(result.bridge?.bridge_status).toBe("answer_draft_ready"); + expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_value_flow_query_movements_v1"); + expect(result.bridge?.pilot.derived_value_flow?.total_amount).toBe(3750); expect(result.bridge?.hot_runtime_wired).toBe(false); expect(result.reason_codes).toContain("mcp_discovery_unsupported_but_understood_turn"); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index d005736..d17a35b 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -49,6 +49,43 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_lifecycle_signal_detected"); }); + it("bootstraps value-flow discovery from raw turnover wording when no exact route owns it", () => { + 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.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_value_flow_signal_detected"); + }); + + it("treats value-flow organization-shaped target as entity candidate when counterparty is absent", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "какой денежный поток был у Группа СВК за 2020 год?", + predecomposeContract: { + entities: { organization: "Группа СВК" }, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toEqual(["Группа СВК"]); + expect(result.turn_meaning_ref?.explicit_organization_scope).toBeUndefined(); + }); + it("does not activate discovery for supported exact current-turn intent", () => { const result = buildAssistantMcpDiscoveryTurnInput({ assistantTurnMeaning: {