From 007369a78ae5cd2492c6054dd85da162646c6b86 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 22 Apr 2026 10:05:03 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20metadata=20ambiguity=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20clarification=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assistantMcpDiscoveryAnswerAdapter.js | 13 +++ .../services/assistantMcpDiscoveryPlanner.js | 9 ++ .../assistantMcpDiscoveryTurnInputAdapter.js | 103 ++++++++++++------ .../src/services/assistantContinuityPolicy.ts | 22 +++- .../assistantMcpDiscoveryPilotExecutor.ts | 3 + .../services/assistantMcpDiscoveryPolicy.ts | 5 + .../assistantMcpDiscoveryTurnInputAdapter.ts | 7 ++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 1 + .../tests/assistantTransitionPolicy.test.ts | 55 ++++++++++ 9 files changed, 179 insertions(+), 39 deletions(-) diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 8510814..7c5dae4 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -73,6 +73,10 @@ function isMovementPilot(pilot) { function isMetadataPilot(pilot) { return pilot.pilot_scope === "metadata_inspection_v1"; } +function isMetadataLaneChoiceClarification(pilot) { + 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) { if (routeFamily === "document_evidence") { return "контур документов"; @@ -127,6 +131,12 @@ function headlineFor(mode, pilot) { 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С."; } @@ -136,6 +146,9 @@ function headlineFor(mode, pilot) { return "Я проверил доступный контур, но подтвержденного факта для ответа не получил."; } function nextStepFor(mode, pilot) { + if (mode === "needs_clarification" && isMetadataLaneChoiceClarification(pilot)) { + return "Уточните, в какой контур идти дальше: по документам или по движениям/регистрам."; + } if (mode === "needs_clarification") { return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С."; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index 28ee741..8eff4a5 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -82,6 +82,15 @@ function recipeFor(input) { const axes = []; 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/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 1d77c4e..1fd467a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -441,6 +441,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) { !metadataDocumentHintSignal && !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 || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "document_evidence") || @@ -450,29 +461,36 @@ function buildAssistantMcpDiscoveryTurnInput(input) { (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "movement_evidence") || metadataAmbiguityCollapsedMovementLaneContinuationApplicable; const effectiveMetadataFollowupSeedApplicable = metadataFollowupSeedApplicable && + !metadataAmbiguityLaneClarificationApplicable && !metadataGroundedDocumentLaneApplicable && !metadataGroundedMovementLaneApplicable; - const seededDomain = metadataGroundedDocumentLaneApplicable - ? "documents" - : metadataGroundedMovementLaneApplicable - ? "movements" - : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable - ? followupSeed.domain - : null; - const seededAction = metadataGroundedDocumentLaneApplicable - ? "list_documents" - : metadataGroundedMovementLaneApplicable - ? "list_movements" - : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable - ? followupSeed.action - : null; - const seededUnsupported = metadataGroundedDocumentLaneApplicable - ? "document_evidence" - : metadataGroundedMovementLaneApplicable - ? "movement_evidence" - : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable - ? followupSeed.unsupported - : null; + const seededDomain = metadataAmbiguityLaneClarificationApplicable + ? "metadata" + : metadataGroundedDocumentLaneApplicable + ? "documents" + : metadataGroundedMovementLaneApplicable + ? "movements" + : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable + ? followupSeed.domain + : null; + const seededAction = metadataAmbiguityLaneClarificationApplicable + ? "resolve_next_lane" + : metadataGroundedDocumentLaneApplicable + ? "list_documents" + : metadataGroundedMovementLaneApplicable + ? "list_movements" + : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable + ? followupSeed.action + : null; + const seededUnsupported = metadataAmbiguityLaneClarificationApplicable + ? "metadata_lane_choice_clarification" + : metadataGroundedDocumentLaneApplicable + ? "document_evidence" + : metadataGroundedMovementLaneApplicable + ? "movement_evidence" + : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable + ? followupSeed.unsupported + : null; const lifecycleSignal = rawLifecycleSignal || seededDomain === "counterparty_lifecycle"; const bidirectionalValueFlowSignal = !lifecycleSignal && (rawBidirectionalValueFlowSignal || seededAction === "net_value_flow"); @@ -482,14 +500,16 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const payoutSignal = 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); @@ -533,6 +553,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, + metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 + ? followupSeed.metadataAmbiguityEntitySets + : undefined, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: explicitDateScope, unsupported_but_understood_family: unsupported ?? @@ -548,17 +571,20 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "movement_evidence" : metadataGroundedDocumentLaneApplicable ? "document_evidence" - : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable - ? "1c_metadata_surface" - : followupDiscoverySeedApplicable - ? seededUnsupported - : null), + : metadataAmbiguityLaneClarificationApplicable + ? "metadata_lane_choice_clarification" + : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + ? "1c_metadata_surface" + : followupDiscoverySeedApplicable + ? seededUnsupported + : null), stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal || valueFlowSignal || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || + metadataAmbiguityLaneClarificationApplicable || rawMetadataSignal || effectiveMetadataFollowupSeedApplicable || followupDiscoverySeedApplicable) @@ -576,6 +602,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } + if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { + cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; + } if (toNonEmptyString(turnMeaning.explicit_organization_scope)) { cleanTurnMeaning.explicit_organization_scope = turnMeaning.explicit_organization_scope; } @@ -597,13 +626,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { explicitIntentCandidate, followupDiscoverySeedApplicable: followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable || + metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal = assistantTurnMeaning ? "assistant_turn_meaning" - : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable + : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable ? "followup_context" : metadataGroundedMovementLaneApplicable ? "followup_context" @@ -663,6 +693,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { 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/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 939b7ed..39b7c5c 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -142,6 +142,17 @@ function readAssistantMcpDiscoveryTurnMeaning( return toRecordObject(turnInput?.turn_meaning_ref); } +function readAssistantMcpDiscoveryTurnMeaningMetadataAmbiguityEntitySets( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string[] { + const values = readAssistantMcpDiscoveryTurnMeaning(debug)?.metadata_ambiguity_entity_sets; + if (!Array.isArray(values)) { + return []; + } + return values.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)); +} + function readAssistantMcpDiscoveryActionFamily( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString @@ -189,7 +200,10 @@ export function readAssistantMcpDiscoveryMetadataSelectedEntitySet( export function readAssistantMcpDiscoveryMetadataAmbiguityDetected( debug: Record | null ): boolean { - return readAssistantMcpDiscoveryDerivedMetadataSurface(debug)?.ambiguity_detected === true; + return ( + readAssistantMcpDiscoveryDerivedMetadataSurface(debug)?.ambiguity_detected === true || + readAssistantMcpDiscoveryTurnMeaningMetadataAmbiguityEntitySets(debug).length > 0 + ); } export function readAssistantMcpDiscoveryMetadataAmbiguityEntitySets( @@ -197,10 +211,10 @@ export function readAssistantMcpDiscoveryMetadataAmbiguityEntitySets( toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString ): string[] { const values = readAssistantMcpDiscoveryDerivedMetadataSurface(debug)?.ambiguity_entity_sets; - if (!Array.isArray(values)) { - return []; + if (Array.isArray(values)) { + return values.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)); } - return values.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)); + return readAssistantMcpDiscoveryTurnMeaningMetadataAmbiguityEntitySets(debug, toNonEmptyString); } function mapAssistantMcpDiscoveryPilotScopeToAddressIntent( diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index 008676f..baa4d12 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -1562,6 +1562,9 @@ function buildEmptyEvidence( } function pilotScopeForPlanner(planner: AssistantMcpDiscoveryPlannerContract): AssistantMcpDiscoveryPilotScope { + if (planner.reason_codes.includes("planner_selected_metadata_lane_clarification_recipe")) { + return "metadata_inspection_v1"; + } if (isMetadataPilotEligible(planner)) { return "metadata_inspection_v1"; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index fa44ce1..effb0a7 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -25,6 +25,7 @@ export interface AssistantMcpDiscoveryTurnMeaningRef { asked_action_family?: string | null; asked_aggregation_axis?: string | null; explicit_entity_candidates?: string[]; + metadata_ambiguity_entity_sets?: string[]; explicit_organization_scope?: string | null; explicit_date_scope?: string | null; meaning_confidence?: number | null; @@ -170,6 +171,7 @@ function normalizeTurnMeaning( const dateScope = toNonEmptyString(value.explicit_date_scope); const unsupported = toNonEmptyString(value.unsupported_but_understood_family); const entities = toStringList(value.explicit_entity_candidates); + const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets); if (domain) { result.asked_domain_family = domain; } @@ -182,6 +184,9 @@ function normalizeTurnMeaning( if (entities.length > 0) { result.explicit_entity_candidates = entities; } + if (metadataAmbiguityEntitySets.length > 0) { + result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets; + } if (organization) { result.explicit_organization_scope = organization; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 57cc433..5821e95 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -725,6 +725,10 @@ export function buildAssistantMcpDiscoveryTurnInput( : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, + metadata_ambiguity_entity_sets: + metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 + ? followupSeed.metadataAmbiguityEntitySets + : undefined, explicit_organization_scope: explicitOrganizationScope, explicit_date_scope: explicitDateScope, unsupported_but_understood_family: @@ -775,6 +779,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } + if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { + cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; + } if (toNonEmptyString(turnMeaning.explicit_organization_scope)) { cleanTurnMeaning.explicit_organization_scope = turnMeaning.explicit_organization_scope; } diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index b2a35dd..183df96 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -595,6 +595,7 @@ describe("assistant MCP discovery turn input adapter", () => { asked_domain_family: "metadata", asked_action_family: "resolve_next_lane", explicit_entity_candidates: ["SVK"], + metadata_ambiguity_entity_sets: ["Документ", "РегистрНакопления"], explicit_date_scope: "2020", unsupported_but_understood_family: "metadata_lane_choice_clarification", stale_replay_forbidden: true diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index c0e62a0..1f87937 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1227,6 +1227,61 @@ describe("assistantTransitionPolicy", () => { "РегистрНакопления" ]); }); + it("preserves metadata ambiguity choice sets through a clarification assistant turn", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "уточните: по документам или по движениям?", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + asked_domain_family: "metadata", + asked_action_family: "resolve_next_lane", + explicit_entity_candidates: ["SVK"], + metadata_ambiguity_entity_sets: ["Документ", "РегистрНакопления"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "metadata_lane_choice_clarification" + } + }, + bridge: { + bridge_status: "needs_clarification", + business_fact_answer_allowed: false, + pilot: { + pilot_scope: "metadata_inspection_v1" + }, + answer_draft: { + answer_mode: "needs_clarification" + } + } + } + } + }), + hasAddressFollowupContextSignal: () => true, + hasReferentialPointer: () => false, + resolveAddressIntent: () => ({ intent: "unknown" }), + resolveAddressIntentFamily: () => null, + resolveAssistantTurnMeaning: () => null + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "по движениям", + [{ kind: "assistant", text: "уточните: по документам или по движениям?" }], + "по движениям", + { predecomposeContract: { intent: "unknown" } }, + null + ); + + expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe("metadata_inspection_v1"); + expect(carryover?.followupContext?.previous_discovery_metadata_ambiguity_detected).toBe(true); + expect(carryover?.followupContext?.previous_discovery_metadata_ambiguity_entity_sets).toEqual([ + "Документ", + "РегистрНакопления" + ]); + }); it("switches to VAT tax-period intent while preserving carried period filters", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({