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 2e660ce..3f1bd72 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 @@ -1045,6 +1045,52 @@ Known next quality gap: - the guarded candidate is now honest and safe, but it still does not compute and verbalize the exact activity duration such as "5 years N months" from first/latest confirmed rows. That belongs to the next evidence-derivation slice, not to the response gate itself. +## Progress Update - 2026-04-20 MCP Discovery Activity Duration Derivation + +The fifteenth implementation slice of Big Block 5 closed the next quality gap in the guarded discovery answer: + +- `assistantMcpDiscoveryPilotExecutor.ts` +- `assistantMcpDiscoveryAnswerAdapter.ts` +- `assistantMcpDiscoveryPilotExecutor.test.ts` +- `assistantMcpDiscoveryAnswerAdapter.test.ts` +- strengthened `address_truth_harness_phase19_mcp_discovery_response_gate.json` + +The pilot now extracts a structured `derived_activity_period` from confirmed lifecycle rows: + +- `first_activity_date`; +- `latest_activity_date`; +- `matched_rows`; +- `duration_total_months`; +- `duration_years`; +- `duration_months_remainder`; +- `duration_human_ru`; +- `inference_basis=first_and_latest_confirmed_1c_activity_rows`. + +The answer adapter now verbalizes this as a user-facing bounded inference. + +Example from the successful replay: + +- first activity: `2014-10-06`; +- latest activity: `2022-04-13`; +- derived duration: `7 лет 6 месяцев`; +- answer wording: "По подтвержденным строкам активности в 1С период взаимодействия можно оценить примерно как 7 лет 6 месяцев... Это вывод по данным 1С, а не юридически подтвержденный возраст регистрации." + +The generic inference sentence is suppressed when the structured duration line exists, so the final answer stays compact instead of repeating the same idea twice. + +Replay result: + +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun2` passed 4/4 after adding duration acceptance; +- `address_truth_harness_phase19_mcp_discovery_response_gate_live_rerun3` passed 4/4 after removing duplicate generic inference wording; +- final status stayed `accepted`; +- lifecycle answer source stayed `mcp_discovery_response_candidate_guarded`; +- debug confirmed `mcp_discovery_response_applied=true` and populated `derived_activity_period`. + +Validation: + +- `npm test -- assistantMcpDiscoveryPilotExecutor.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryResponsePolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts` passed 28/28; +- `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`. + ## 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 9c05626..8fc8811 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 @@ -61,6 +61,7 @@ "(?i)свк", "(?i)1с|активност|подтвержд", "(?i)вывод|оцен|инфер|можно оцен", + "(?i)лет|год|месяц", "(?i)юридическ|регистрац|не подтвержд|не доказ" ], "forbidden_answer_patterns": [ diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 2f78692..9d9275a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -97,6 +97,17 @@ function buildMustNotClaim(pilot) { } return claims; } +function derivedActivityInferenceLine(pilot) { + const period = pilot.derived_activity_period; + if (!period) { + return null; + } + return [ + `По подтвержденным строкам активности в 1С период взаимодействия можно оценить примерно как ${period.duration_human_ru}.`, + `Первая найденная активность: ${period.first_activity_date}; последняя найденная активность: ${period.latest_activity_date}.`, + "Это вывод по данным 1С, а не юридически подтвержденный возраст регистрации." + ].join(" "); +} function buildAssistantMcpDiscoveryAnswerDraft(pilot) { const mode = modeFor(pilot); const reasonCodes = [...pilot.reason_codes, ...pilot.evidence.reason_codes]; @@ -107,13 +118,17 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { if (pilot.evidence.inferred_facts.length > 0) { pushReason(reasonCodes, "answer_contains_bounded_inference"); } + const derivedInferenceLine = derivedActivityInferenceLine(pilot); + const inferenceLines = derivedInferenceLine + ? [derivedInferenceLine] + : pilot.evidence.inferred_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), - inference_lines: uniqueStrings(pilot.evidence.inferred_facts), + inference_lines: uniqueStrings(inferenceLines), unknown_lines: uniqueStrings(pilot.evidence.unknown_facts), limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]), next_step_line: nextStepFor(mode, pilot), diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 975cfa4..cd31d20 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -116,6 +116,74 @@ function summarizeRows(result) { } return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`; } +function rowDateValue(row) { + const candidates = [ + row["Период"], + row["Period"], + row["period"], + row["Дата"], + row["Date"], + row["date"] + ]; + for (const candidate of candidates) { + const text = toNonEmptyString(candidate); + const match = text?.match(/(\d{4})-(\d{2})-(\d{2})/); + if (match) { + return `${match[1]}-${match[2]}-${match[3]}`; + } + } + return null; +} +function monthDiff(firstIsoDate, latestIsoDate) { + const first = new Date(`${firstIsoDate}T00:00:00.000Z`); + const latest = new Date(`${latestIsoDate}T00:00:00.000Z`); + if (Number.isNaN(first.getTime()) || Number.isNaN(latest.getTime()) || latest < first) { + return 0; + } + let months = (latest.getUTCFullYear() - first.getUTCFullYear()) * 12; + months += latest.getUTCMonth() - first.getUTCMonth(); + if (latest.getUTCDate() < first.getUTCDate()) { + months -= 1; + } + return Math.max(0, months); +} +function formatDurationHumanRu(totalMonths) { + const years = Math.floor(totalMonths / 12); + const months = totalMonths % 12; + const parts = []; + if (years > 0) { + parts.push(`${years} ${years === 1 ? "год" : years >= 2 && years <= 4 ? "года" : "лет"}`); + } + if (months > 0) { + parts.push(`${months} ${months === 1 ? "месяц" : months >= 2 && months <= 4 ? "месяца" : "месяцев"}`); + } + return parts.length > 0 ? parts.join(" ") : "меньше месяца"; +} +function deriveActivityPeriod(result) { + if (!result || result.error || result.matched_rows <= 0) { + return null; + } + const dates = result.rows + .map((row) => rowDateValue(row)) + .filter((value) => Boolean(value)) + .sort(); + if (dates.length === 0) { + return null; + } + const first = dates[0]; + const latest = dates[dates.length - 1]; + const totalMonths = monthDiff(first, latest); + return { + first_activity_date: first, + latest_activity_date: latest, + matched_rows: result.matched_rows, + duration_total_months: totalMonths, + duration_years: Math.floor(totalMonths / 12), + duration_months_remainder: totalMonths % 12, + duration_human_ru: formatDurationHumanRu(totalMonths), + inference_basis: "first_and_latest_confirmed_1c_activity_rows" + }; +} function buildConfirmedFacts(result, counterparty) { if (result.error || result.matched_rows <= 0) { return []; @@ -166,6 +234,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["MCP discovery pilot was blocked before execution"], reason_codes: reasonCodes }; @@ -185,6 +254,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["MCP discovery pilot needs more scope before execution"], reason_codes: reasonCodes }; @@ -208,6 +278,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["MCP discovery pilot scope is not implemented yet"], reason_codes: reasonCodes }; @@ -231,6 +302,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["Lifecycle recipe is not available"], reason_codes: reasonCodes }; @@ -258,6 +330,10 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { } } const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null; + const derivedActivityPeriod = deriveActivityPeriod(queryResult); + if (derivedActivityPeriod) { + pushReason(reasonCodes, "pilot_derived_activity_period_from_confirmed_rows"); + } const evidence = (0, assistantMcpDiscoveryPolicy_1.resolveAssistantMcpDiscoveryEvidence)({ plan: planner.discovery_plan, probeResults, @@ -280,6 +356,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { probe_results: probeResults, evidence, source_rows_summary: sourceRowsSummary, + derived_activity_period: derivedActivityPeriod, query_limitations: queryLimitations, reason_codes: reasonCodes }; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 5c2fe2f..5f08340 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -130,6 +130,18 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): return claims; } +function derivedActivityInferenceLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + const period = pilot.derived_activity_period; + if (!period) { + return null; + } + return [ + `По подтвержденным строкам активности в 1С период взаимодействия можно оценить примерно как ${period.duration_human_ru}.`, + `Первая найденная активность: ${period.first_activity_date}; последняя найденная активность: ${period.latest_activity_date}.`, + "Это вывод по данным 1С, а не юридически подтвержденный возраст регистрации." + ].join(" "); +} + export function buildAssistantMcpDiscoveryAnswerDraft( pilot: AssistantMcpDiscoveryPilotExecutionContract ): AssistantMcpDiscoveryAnswerDraftContract { @@ -142,6 +154,10 @@ export function buildAssistantMcpDiscoveryAnswerDraft( if (pilot.evidence.inferred_facts.length > 0) { pushReason(reasonCodes, "answer_contains_bounded_inference"); } + const derivedInferenceLine = derivedActivityInferenceLine(pilot); + const inferenceLines = derivedInferenceLine + ? [derivedInferenceLine] + : pilot.evidence.inferred_facts; return { schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, @@ -149,7 +165,7 @@ export function buildAssistantMcpDiscoveryAnswerDraft( answer_mode: mode, headline: headlineFor(mode), confirmed_lines: uniqueStrings(pilot.evidence.confirmed_facts), - inference_lines: uniqueStrings(pilot.evidence.inferred_facts), + inference_lines: uniqueStrings(inferenceLines), unknown_lines: uniqueStrings(pilot.evidence.unknown_facts), limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]), next_step_line: nextStepFor(mode, pilot), diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index d28cc14..fce3159 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -29,6 +29,17 @@ export interface AssistantMcpDiscoveryPilotExecutorDeps { executeAddressMcpQuery: typeof executeAddressMcpQuery; } +export interface AssistantMcpDiscoveryDerivedActivityPeriod { + first_activity_date: string; + latest_activity_date: string; + matched_rows: number; + duration_total_months: number; + duration_years: number; + duration_months_remainder: number; + duration_human_ru: string; + inference_basis: "first_and_latest_confirmed_1c_activity_rows"; +} + export interface AssistantMcpDiscoveryPilotExecutionContract { schema_version: typeof ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION; policy_owner: "assistantMcpDiscoveryPilotExecutor"; @@ -41,6 +52,7 @@ export interface AssistantMcpDiscoveryPilotExecutionContract { probe_results: AssistantMcpDiscoveryProbeResult[]; evidence: AssistantMcpDiscoveryEvidenceContract; source_rows_summary: string | null; + derived_activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null; query_limitations: string[]; reason_codes: string[]; } @@ -173,6 +185,80 @@ function summarizeRows(result: AddressMcpQueryExecutorResult): string | null { return `${result.fetched_rows} MCP document rows fetched, ${result.matched_rows} matched lifecycle scope`; } +function rowDateValue(row: Record): string | null { + const candidates = [ + row["Период"], + row["Period"], + row["period"], + row["Дата"], + row["Date"], + row["date"] + ]; + for (const candidate of candidates) { + const text = toNonEmptyString(candidate); + const match = text?.match(/(\d{4})-(\d{2})-(\d{2})/); + if (match) { + return `${match[1]}-${match[2]}-${match[3]}`; + } + } + 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`); + if (Number.isNaN(first.getTime()) || Number.isNaN(latest.getTime()) || latest < first) { + return 0; + } + let months = (latest.getUTCFullYear() - first.getUTCFullYear()) * 12; + months += latest.getUTCMonth() - first.getUTCMonth(); + if (latest.getUTCDate() < first.getUTCDate()) { + months -= 1; + } + return Math.max(0, months); +} + +function formatDurationHumanRu(totalMonths: number): string { + const years = Math.floor(totalMonths / 12); + const months = totalMonths % 12; + const parts: string[] = []; + if (years > 0) { + parts.push(`${years} ${years === 1 ? "год" : years >= 2 && years <= 4 ? "года" : "лет"}`); + } + if (months > 0) { + parts.push(`${months} ${months === 1 ? "месяц" : months >= 2 && months <= 4 ? "месяца" : "месяцев"}`); + } + return parts.length > 0 ? parts.join(" ") : "меньше месяца"; +} + +function deriveActivityPeriod( + result: AddressMcpQueryExecutorResult | null +): AssistantMcpDiscoveryDerivedActivityPeriod | null { + if (!result || result.error || result.matched_rows <= 0) { + return null; + } + const dates = result.rows + .map((row) => rowDateValue(row)) + .filter((value): value is string => Boolean(value)) + .sort(); + if (dates.length === 0) { + return null; + } + const first = dates[0]; + const latest = dates[dates.length - 1]; + const totalMonths = monthDiff(first, latest); + return { + first_activity_date: first, + latest_activity_date: latest, + matched_rows: result.matched_rows, + duration_total_months: totalMonths, + duration_years: Math.floor(totalMonths / 12), + duration_months_remainder: totalMonths % 12, + duration_human_ru: formatDurationHumanRu(totalMonths), + inference_basis: "first_and_latest_confirmed_1c_activity_rows" + }; +} + function buildConfirmedFacts(result: AddressMcpQueryExecutorResult, counterparty: string | null): string[] { if (result.error || result.matched_rows <= 0) { return []; @@ -236,6 +322,7 @@ export async function executeAssistantMcpDiscoveryPilot( probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["MCP discovery pilot was blocked before execution"], reason_codes: reasonCodes }; @@ -256,6 +343,7 @@ export async function executeAssistantMcpDiscoveryPilot( probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["MCP discovery pilot needs more scope before execution"], reason_codes: reasonCodes }; @@ -280,6 +368,7 @@ export async function executeAssistantMcpDiscoveryPilot( probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["MCP discovery pilot scope is not implemented yet"], reason_codes: reasonCodes }; @@ -304,6 +393,7 @@ export async function executeAssistantMcpDiscoveryPilot( probe_results: probeResults, evidence, source_rows_summary: null, + derived_activity_period: null, query_limitations: ["Lifecycle recipe is not available"], reason_codes: reasonCodes }; @@ -332,6 +422,10 @@ export async function executeAssistantMcpDiscoveryPilot( } const sourceRowsSummary = queryResult ? summarizeRows(queryResult) : null; + const derivedActivityPeriod = deriveActivityPeriod(queryResult); + if (derivedActivityPeriod) { + pushReason(reasonCodes, "pilot_derived_activity_period_from_confirmed_rows"); + } const evidence = resolveAssistantMcpDiscoveryEvidence({ plan: planner.discovery_plan, probeResults, @@ -355,6 +449,7 @@ export async function executeAssistantMcpDiscoveryPilot( probe_results: probeResults, evidence, source_rows_summary: sourceRowsSummary, + derived_activity_period: derivedActivityPeriod, query_limitations: queryLimitations, reason_codes: reasonCodes }; diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 3f3f534..934d86a 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -35,7 +35,9 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.internal_mechanics_allowed).toBe(false); expect(draft.headline).toContain("подтвержденная активность"); expect(draft.confirmed_lines[0]).toContain("SVK"); - expect(draft.inference_lines[0]).toContain("may be inferred"); + expect(draft.inference_lines[0]).toContain("меньше месяца"); + expect(draft.inference_lines.join("\n")).toContain("Первая найденная активность: 2020-01-15"); + expect(draft.inference_lines.join("\n")).toContain("не юридически подтвержденный возраст регистрации"); expect(draft.unknown_lines).toContain("Legal registration date is not proven by this MCP discovery pilot"); expect(draft.must_not_claim).toContain("Do not present inferred activity duration as a formally confirmed legal fact."); expect(draft.reason_codes).toContain("answer_contains_unknown_fact_boundary"); @@ -106,4 +108,30 @@ describe("assistant MCP discovery answer adapter", () => { expect(userFacing).not.toContain("ВЫБРАТЬ"); expect(userFacing).not.toContain("primitive"); }); + + it("verbalizes activity duration from first and latest confirmed 1C rows", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_lifecycle", + asked_action_family: "activity_duration", + explicit_entity_candidates: ["SVK"] + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildDeps([ + { Period: "2020-01-15T00:00:00", Counterparty: "SVK" }, + { Period: "2023-12-20T00:00:00", Counterparty: "SVK" } + ]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + const inferenceText = draft.inference_lines.join("\n"); + + expect(inferenceText).toContain("3 года 11 месяцев"); + expect(inferenceText).toContain("2020-01-15"); + expect(inferenceText).toContain("2023-12-20"); + expect(inferenceText).toContain("не юридически подтвержденный возраст регистрации"); + expect(draft.reason_codes).toContain("pilot_derived_activity_period_from_confirmed_rows"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index 7e952f6..dd191bb 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -39,7 +39,18 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.evidence.inferred_facts[0]).toContain("may be inferred"); expect(result.evidence.unknown_facts).toContain("Legal registration date is not proven by this MCP discovery pilot"); expect(result.source_rows_summary).toBe("2 MCP document rows fetched, 2 matched lifecycle scope"); + expect(result.derived_activity_period).toEqual({ + first_activity_date: "2020-01-15", + latest_activity_date: "2023-12-20", + matched_rows: 2, + duration_total_months: 47, + duration_years: 3, + duration_months_remainder: 11, + duration_human_ru: "3 года 11 месяцев", + inference_basis: "first_and_latest_confirmed_1c_activity_rows" + }); expect(result.reason_codes).toContain("pilot_query_documents_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_activity_period_from_confirmed_rows"); expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1); const call = deps.executeAddressMcpQuery.mock.calls[0]?.[0]; @@ -100,6 +111,7 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.mcp_execution_performed).toBe(true); expect(result.evidence.evidence_status).toBe("insufficient"); expect(result.evidence.confirmed_facts).toEqual([]); + expect(result.derived_activity_period).toBeNull(); expect(result.query_limitations).toContain("MCP fetch failed: timeout"); expect(result.reason_codes).toContain("pilot_query_documents_mcp_error"); });