From 40cf71d1188e3d4446e524e1ab287d964a0455e2 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 22 Apr 2026 10:02:01 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D1=83=D0=B4=D0=B5=D1=80=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20mixed=20metadata=20ambiguity=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D1=8F=D0=B2=D0=BD=D0=BE=D0=B5=20clarifica?= =?UTF-8?q?tion=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assistantMcpDiscoveryAnswerAdapter.ts | 16 ++++++ .../services/assistantMcpDiscoveryPlanner.ts | 10 ++++ .../assistantMcpDiscoveryTurnInputAdapter.ts | 53 ++++++++++++++----- ...assistantMcpDiscoveryAnswerAdapter.test.ts | 21 ++++++++ .../assistantMcpDiscoveryPlanner.test.ts | 20 +++++++ ...stantMcpDiscoveryResponseCandidate.test.ts | 28 ++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 32 +++++++++++ 7 files changed, 168 insertions(+), 12 deletions(-) diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 023407c..1838fc7 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -109,6 +109,13 @@ function isMetadataPilot(pilot: AssistantMcpDiscoveryPilotExecutionContract): bo return pilot.pilot_scope === "metadata_inspection_v1"; } +function isMetadataLaneChoiceClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { + return ( + pilot.reason_codes.includes("planner_selected_metadata_lane_clarification_recipe") || + pilot.dry_run.reason_codes.includes("planner_selected_metadata_lane_clarification_recipe") + ); +} + function metadataRouteFamilyLabelRu( routeFamily: "document_evidence" | "movement_evidence" | "catalog_drilldown" | null ): string | null { @@ -167,6 +174,12 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (mode === "bounded_inference_only") { return "Точный факт не подтвержден, но есть ограниченная оценка по найденной активности в 1С."; } + if (mode === "needs_clarification" && isMetadataLaneChoiceClarification(pilot)) { + return "По подтвержденной metadata-поверхности видно несколько конкурирующих data-lane, и без явного выбора дальше идти нельзя."; + } + if (mode === "needs_clarification" && isMetadataLaneChoiceClarification(pilot)) { + return "Уточните, в какой контур идти дальше: по документам или по движениям/регистрам."; + } if (mode === "needs_clarification") { return "Нужно уточнить контекст перед поиском в 1С."; } @@ -177,6 +190,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD } function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + if (mode === "needs_clarification" && isMetadataLaneChoiceClarification(pilot)) { + return "Уточните, в какой контур идти дальше: по документам или по движениям/регистрам."; + } if (mode === "needs_clarification") { return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С."; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index 2c7f850..a48357b 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -131,6 +131,16 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const requestedAggregationAxis = aggregationAxis(meaning); addScopeAxes(axes, meaning); + if (includesAny(combined, ["metadata_lane_choice_clarification", "resolve_next_lane"])) { + pushUnique(axes, "lane_family_choice"); + return { + semanticDataNeed: "metadata lane clarification", + primitives: [], + axes, + reason: "planner_selected_metadata_lane_clarification_recipe" + }; + } + if (includesAny(combined, ["turnover", "revenue", "payment", "payout", "value", "net", "netting", "balance", "cashflow"])) { pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 936d31b..57cc433 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -601,6 +601,19 @@ export function buildAssistantMcpDiscoveryTurnInput( !metadataMovementHintSignal && hasMetadataDownstreamContinuationSignal(rawText) ); + const metadataAmbiguityLaneClarificationApplicable = Boolean( + followupSeed.pilotScope === "metadata_inspection_v1" && + followupSeed.metadataAmbiguityDetected && + !metadataAmbiguityCollapsesToDocumentLane(followupSeed.metadataAmbiguityEntitySets) && + !metadataAmbiguityCollapsesToMovementLane(followupSeed.metadataAmbiguityEntitySets) && + followupSeed.counterparty && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + !metadataDocumentHintSignal && + !metadataMovementHintSignal && + hasMetadataDownstreamContinuationSignal(rawText) + ); const metadataGroundedDocumentLaneApplicable = metadataGroundedDocumentFollowupApplicable || metadataAmbiguityResolvedDocumentFollowupApplicable || @@ -613,23 +626,30 @@ export function buildAssistantMcpDiscoveryTurnInput( metadataAmbiguityCollapsedMovementLaneContinuationApplicable; const effectiveMetadataFollowupSeedApplicable = metadataFollowupSeedApplicable && + !metadataAmbiguityLaneClarificationApplicable && !metadataGroundedDocumentLaneApplicable && !metadataGroundedMovementLaneApplicable; - const seededDomain = metadataGroundedDocumentLaneApplicable + const seededDomain = metadataAmbiguityLaneClarificationApplicable + ? "metadata" + : metadataGroundedDocumentLaneApplicable ? "documents" : metadataGroundedMovementLaneApplicable ? "movements" : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable ? followupSeed.domain : null; - const seededAction = metadataGroundedDocumentLaneApplicable + const seededAction = metadataAmbiguityLaneClarificationApplicable + ? "resolve_next_lane" + : metadataGroundedDocumentLaneApplicable ? "list_documents" : metadataGroundedMovementLaneApplicable ? "list_movements" : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable ? followupSeed.action : null; - const seededUnsupported = metadataGroundedDocumentLaneApplicable + const seededUnsupported = metadataAmbiguityLaneClarificationApplicable + ? "metadata_lane_choice_clarification" + : metadataGroundedDocumentLaneApplicable ? "document_evidence" : metadataGroundedMovementLaneApplicable ? "movement_evidence" @@ -649,14 +669,16 @@ export function buildAssistantMcpDiscoveryTurnInput( valueFlowSignal && !bidirectionalValueFlowSignal && (rawPayoutSignal || seededAction === "payout"); - const semanticDataNeed = semanticNeedFor({ - domain: rawDomain ?? seededDomain, - action: rawAction ?? seededAction, - unsupported: unsupported ?? seededUnsupported, - lifecycleSignal, - valueFlowSignal, - metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable - }); + const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable + ? "metadata lane clarification" + : semanticNeedFor({ + domain: rawDomain ?? seededDomain, + action: rawAction ?? seededAction, + unsupported: unsupported ?? seededUnsupported, + lifecycleSignal, + valueFlowSignal, + metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + }); const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); pushUnique(entityCandidates, predecomposeEntities.counterparty); pushUnique(entityCandidates, followupSeed.counterparty); @@ -719,6 +741,8 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "movement_evidence" : metadataGroundedDocumentLaneApplicable ? "document_evidence" + : metadataAmbiguityLaneClarificationApplicable + ? "metadata_lane_choice_clarification" : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable ? "1c_metadata_surface" : followupDiscoverySeedApplicable @@ -731,6 +755,7 @@ export function buildAssistantMcpDiscoveryTurnInput( valueFlowSignal || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || + metadataAmbiguityLaneClarificationApplicable || rawMetadataSignal || effectiveMetadataFollowupSeedApplicable || followupDiscoverySeedApplicable @@ -773,13 +798,14 @@ export function buildAssistantMcpDiscoveryTurnInput( followupDiscoverySeedApplicable: followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable || + metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal: AssistantMcpDiscoveryTurnInputSource = assistantTurnMeaning ? "assistant_turn_meaning" - : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable + : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable ? "followup_context" : metadataGroundedMovementLaneApplicable ? "followup_context" @@ -840,6 +866,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (metadataAmbiguityCollapsedMovementLaneContinuationApplicable) { pushReason(reasonCodes, "mcp_discovery_metadata_ambiguity_collapsed_to_movement_lane"); } + if (metadataAmbiguityLaneClarificationApplicable) { + pushReason(reasonCodes, "mcp_discovery_metadata_ambiguity_requires_lane_choice"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index f09881f..c71bb8c 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -162,6 +162,27 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.must_not_claim).toContain("Do not claim rows were checked when mcp_execution_performed=false."); }); + it("asks for an explicit lane choice when mixed metadata ambiguity cannot continue on a neutral follow-up", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "metadata", + asked_action_family: "resolve_next_lane", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "metadata_lane_choice_clarification" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([])); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.answer_mode).toBe("needs_clarification"); + expect(draft.headline).toContain("data-lane"); + expect(draft.next_step_line).toContain("по документам"); + expect(draft.next_step_line).toContain("по движениям/регистрам"); + expect(draft.must_not_claim).toContain("Do not claim rows were checked when mcp_execution_performed=false."); + }); + it("turns metadata surface evidence into a human-safe metadata answer draft", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index bb605c5..4a26bbf 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -152,6 +152,26 @@ describe("assistant MCP discovery planner", () => { expect(result.proposed_primitives).not.toContain("query_documents"); }); + it("keeps metadata lane-choice clarification in needs_clarification without launching MCP primitives", () => { + const result = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "metadata", + asked_action_family: "resolve_next_lane", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "metadata_lane_choice_clarification" + } + }); + + expect(result.planner_status).toBe("needs_clarification"); + expect(result.semantic_data_need).toBe("metadata lane clarification"); + expect(result.proposed_primitives).toEqual([]); + expect(result.required_axes).toEqual(["counterparty", "period", "lane_family_choice"]); + expect(result.discovery_plan.plan_status).toBe("needs_clarification"); + expect(result.reason_codes).toContain("planner_selected_metadata_lane_clarification_recipe"); + expect(result.reason_codes).toContain("planner_needs_more_user_or_scope_context"); + }); + it("does not mark an unclassified turn as executable without turn meaning context", () => { const result = planAssistantMcpDiscovery({}); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 606088e..ae7a5a6 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -351,6 +351,34 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.eligible_for_future_hot_runtime).toBe(true); }); + it("surfaces metadata lane-choice clarification as a user-facing clarification candidate", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + bridge: { + bridge_status: "needs_clarification", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "По подтвержденной metadata-поверхности видно несколько конкурирующих data-lane, и без явного выбора дальше идти нельзя.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: "Уточните, в какой контур идти дальше: по документам или по движениям/регистрам." + } + } + }) + ); + + expect(candidate.candidate_status).toBe("clarification_candidate"); + expect(candidate.reply_type).toBe("clarification_required"); + expect(candidate.reply_text).toContain("data-lane"); + expect(candidate.reply_text).toContain("по документам"); + expect(candidate.reply_text).toContain("по движениям/регистрам"); + }); + it("does not expose unsupported bridge output as a future hot candidate", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index eae42fc..b2a35dd 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -570,6 +570,38 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_metadata_ambiguity_collapsed_to_movement_lane"); }); + it("requires an explicit lane choice on a generic downstream follow-up when metadata ambiguity stays mixed", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "давай дальше", + followupContext: { + previous_discovery_pilot_scope: "metadata_inspection_v1", + previous_discovery_metadata_ambiguity_detected: true, + previous_discovery_metadata_ambiguity_entity_sets: ["Документ", "РегистрНакопления"], + previous_filters: { + counterparty: "SVK", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "SVK" + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.source_signal).toBe("followup_context"); + expect(result.semantic_data_need).toBe("metadata lane clarification"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "metadata", + asked_action_family: "resolve_next_lane", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "metadata_lane_choice_clarification", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_metadata_ambiguity_requires_lane_choice"); + }); + it("switches the checked year on a short payout follow-up while keeping prior discovery counterparty", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "а теперь за 2021?",