From bd58ab490f3368254d41eadf2e73d284dc36c21d Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 22 Apr 2026 17:32:24 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B4=D0=BE=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20planner-selected=20entity=20chains=20=D0=B8=20ambiguit?= =?UTF-8?q?y=20follow-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/assistantContinuityPolicy.js | 12 ++ .../assistantMcpDiscoveryAnswerAdapter.js | 12 +- .../assistantMcpDiscoveryPilotExecutor.js | 126 +++++++++++-- .../assistantMcpDiscoveryResponsePolicy.js | 68 ++++++- .../assistantMcpDiscoveryTurnInputAdapter.js | 141 ++++++++++++++- .../services/assistantTransitionPolicy.js | 6 + .../address_runtime/decomposeStage.ts | 2 + .../src/services/assistantContinuityPolicy.ts | 18 ++ .../assistantMcpDiscoveryAnswerAdapter.ts | 17 +- .../assistantMcpDiscoveryPilotExecutor.ts | 143 +++++++++++++-- .../assistantMcpDiscoveryResponsePolicy.ts | 81 ++++++++- .../assistantMcpDiscoveryTurnInputAdapter.ts | 166 +++++++++++++++++- .../src/services/assistantTransitionPolicy.ts | 15 ++ ...assistantMcpDiscoveryAnswerAdapter.test.ts | 7 +- ...assistantMcpDiscoveryPilotExecutor.test.ts | 31 +++- ...ssistantMcpDiscoveryResponsePolicy.test.ts | 69 ++++++++ ...stantMcpDiscoveryRuntimeEntryPoint.test.ts | 128 ++++++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 157 +++++++++++++++++ .../tests/assistantTransitionPolicy.test.ts | 59 +++++++ 19 files changed, 1193 insertions(+), 65 deletions(-) diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 15b1316..16487f1 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -1,5 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.readAssistantMcpDiscoveryEntityResolutionStatus = readAssistantMcpDiscoveryEntityResolutionStatus; +exports.readAssistantMcpDiscoveryEntityAmbiguityCandidates = readAssistantMcpDiscoveryEntityAmbiguityCandidates; exports.readAssistantMcpDiscoveryEntityCandidates = readAssistantMcpDiscoveryEntityCandidates; exports.readAssistantMcpDiscoveryPilotScope = readAssistantMcpDiscoveryPilotScope; exports.readAssistantMcpDiscoveryMetadataRouteFamily = readAssistantMcpDiscoveryMetadataRouteFamily; @@ -97,6 +99,16 @@ function readAssistantMcpDiscoveryDerivedEntityResolution(debug) { const pilot = toRecordObject(bridge?.pilot); return toRecordObject(pilot?.derived_entity_resolution); } +function readAssistantMcpDiscoveryEntityResolutionStatus(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryDerivedEntityResolution(debug)?.resolution_status); +} +function readAssistantMcpDiscoveryEntityAmbiguityCandidates(debug, toNonEmptyString = fallbackToNonEmptyString) { + const values = readAssistantMcpDiscoveryDerivedEntityResolution(debug)?.ambiguity_candidates; + if (!Array.isArray(values)) { + return []; + } + return values.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)); +} function collectAssistantMcpDiscoveryEntityCandidates(debug, toNonEmptyString = fallbackToNonEmptyString) { const result = []; const resolution = readAssistantMcpDiscoveryDerivedEntityResolution(debug); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 23c7b02..62d5824 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -27,6 +27,12 @@ function uniqueStrings(values) { } return result; } +function formatNamedChoiceList(values) { + return uniqueStrings(values) + .slice(0, 6) + .map((value, index) => `${index + 1}. ${value}`) + .join("; "); +} function isInternalMechanicsLine(value) { const text = value.toLowerCase(); return (text.includes("primitive") || @@ -273,6 +279,10 @@ function headlineFor(mode, pilot) { } function nextStepFor(mode, pilot) { if (isEntityResolutionPilot(pilot) && mode === "needs_clarification") { + const ambiguityCandidates = pilot.derived_entity_resolution?.ambiguity_candidates ?? []; + if (ambiguityCandidates.length > 0) { + return `Уточните, какой именно контрагент нужен: ${formatNamedChoiceList(ambiguityCandidates)}. Можно ответить названием или номером варианта.`; + } return "Уточните точное название контрагента или добавьте ИНН, и я продолжу уже по нужной сущности в 1С."; } if (isEntityResolutionPilot(pilot) && mode === "confirmed_with_bounded_inference") { @@ -449,7 +459,7 @@ function derivedEntityResolutionInferenceLine(pilot) { return "Сейчас подтверждено только заземление сущности по каталогу 1С; документы, движения и денежные показатели по ней еще не проверялись."; } if (resolution.resolution_status === "ambiguous" && resolution.ambiguity_candidates.length > 0) { - return `В checked catalog slice есть несколько близких кандидатов: ${resolution.ambiguity_candidates.join(", ")}. Без уточнения нельзя честно выбрать одного контрагента для следующего шага.`; + return `В каталоге 1С нашлось несколько близких кандидатов: ${formatNamedChoiceList(resolution.ambiguity_candidates)}. Без уточнения нельзя честно выбрать одного контрагента для следующего шага.`; } return null; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index a5a95dd..b226798 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -655,6 +655,46 @@ function summarizeEntityResolutionRows(result) { } return `${result.fetched_rows} MCP catalog rows fetched for entity search`; } +function entityResolutionFollowupStepLimitation() { + return "Entity-resolution could not continue because the checked catalog search step did not return a confirmed slice"; +} +function buildEntityResolutionResolveProbeResult(input) { + if (!input.resolution) { + return { + primitive_id: "resolve_entity_reference", + status: "ok", + rows_received: input.queryResult.fetched_rows, + rows_matched: 0, + limitation: null + }; + } + if (input.resolution.resolution_status === "resolved") { + return { + primitive_id: "resolve_entity_reference", + status: "ok", + rows_received: input.queryResult.fetched_rows, + rows_matched: 1, + limitation: null + }; + } + return { + primitive_id: "resolve_entity_reference", + status: "ok", + rows_received: input.queryResult.fetched_rows, + rows_matched: 0, + limitation: null + }; +} +function buildEntityResolutionCoverageProbeResult(input) { + const resolved = input.resolution?.resolution_status === "resolved"; + return { + primitive_id: "probe_coverage", + status: "ok", + rows_received: 1, + rows_matched: resolved ? 1 : 0, + limitation: null + }; +} function metadataRowText(row, keys) { for (const key of keys) { const text = toNonEmptyString(row[key]); @@ -1644,28 +1684,82 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { reason_codes: reasonCodes }; } + let derivedEntityResolution = null; for (const step of dryRun.execution_steps) { - if (step.primitive_id !== "search_business_entity") { - skippedPrimitives.push(step.primitive_id); - probeResults.push(skippedProbeResult(step, "pilot_only_executes_search_business_entity")); + if (step.primitive_id === "search_business_entity") { + queryResult = await runtimeDeps.executeAddressMcpQuery({ + query: ENTITY_RESOLUTION_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT)), + limit: ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT + }); + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); + if (queryResult.error) { + pushUnique(queryLimitations, queryResult.error); + pushReason(reasonCodes, "pilot_search_business_entity_mcp_error"); + } + else { + pushReason(reasonCodes, "pilot_search_business_entity_mcp_executed"); + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } continue; } - queryResult = await runtimeDeps.executeAddressMcpQuery({ - query: ENTITY_RESOLUTION_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT)), - limit: ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT - }); - pushUnique(executedPrimitives, step.primitive_id); - probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); - if (queryResult.error) { - pushUnique(queryLimitations, queryResult.error); - pushReason(reasonCodes, "pilot_search_business_entity_mcp_error"); + if (step.primitive_id === "resolve_entity_reference") { + if (!queryResult || queryResult.error) { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, entityResolutionFollowupStepLimitation())); + continue; + } + if (!derivedEntityResolution) { + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(buildEntityResolutionResolveProbeResult({ + queryResult, + resolution: derivedEntityResolution + })); + if (derivedEntityResolution?.resolution_status === "resolved") { + pushReason(reasonCodes, "pilot_resolve_entity_reference_from_catalog_rows"); + } + else if (derivedEntityResolution?.resolution_status === "ambiguous") { + pushReason(reasonCodes, "pilot_resolve_entity_reference_requires_clarification"); + } + else { + pushReason(reasonCodes, "pilot_resolve_entity_reference_not_confirmed"); + } + continue; } - else { - pushReason(reasonCodes, "pilot_search_business_entity_mcp_executed"); + if (step.primitive_id === "probe_coverage") { + if (!queryResult || queryResult.error) { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, entityResolutionFollowupStepLimitation())); + continue; + } + if (!derivedEntityResolution) { + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(buildEntityResolutionCoverageProbeResult({ + resolution: derivedEntityResolution + })); + pushReason(reasonCodes, "pilot_probe_coverage_executed_for_entity_resolution"); + if (derivedEntityResolution?.resolution_status === "resolved") { + pushReason(reasonCodes, "pilot_entity_resolution_grounding_stable_for_downstream_probe"); + } + else if (derivedEntityResolution?.resolution_status === "ambiguous") { + pushReason(reasonCodes, "pilot_entity_resolution_coverage_requires_clarification"); + } + else { + pushReason(reasonCodes, "pilot_entity_resolution_coverage_not_confirmed"); + } + continue; } + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_entity_resolution_step_not_implemented")); } const sourceRowsSummary = queryResult ? summarizeEntityResolutionRows(queryResult) : null; - const derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + if (!derivedEntityResolution && queryResult && !queryResult.error) { + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } if (derivedEntityResolution?.resolution_status === "resolved") { pushReason(reasonCodes, "pilot_derived_entity_resolution_from_catalog_rows"); } @@ -1683,7 +1777,7 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { unknownFacts: buildEntityResolutionUnknownFacts(derivedEntityResolution, requestedEntity), sourceRowsSummary, queryLimitations, - recommendedNextProbe: "resolve_entity_reference" + recommendedNextProbe: null }); return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 2d280c7..a7f4eb2 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -138,11 +138,63 @@ function readDiscoveryTurnMeaning(entryPoint) { const turnInput = toRecordObject(entryPoint?.turn_input); return toRecordObject(turnInput?.turn_meaning_ref); } +function readTruthAnswerShape(input) { + const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); + if (directShape) { + return directShape; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + return toRecordObject(truthAnswerPolicy?.answer_shape); +} +function hasEffectivelyFactualAddressReply(input) { + if (toNonEmptyString(input.currentReplyType) === "factual") { + return true; + } + const truthAnswerShape = readTruthAnswerShape(input); + return toNonEmptyString(truthAnswerShape?.reply_type) === "factual"; +} +function readStateTransitionReasonCodes(input) { + const directTransition = toRecordObject(input.addressRuntimeMeta?.assistant_state_transition_v1); + const fallbackTransition = toRecordObject(input.addressRuntimeMeta?.state_transition_contract); + const stateTransition = directTransition ?? fallbackTransition; + if (!stateTransition || !Array.isArray(stateTransition.reason_codes)) { + return []; + } + return stateTransition.reason_codes + .map((item) => toNonEmptyString(item)) + .filter((item) => Boolean(item)); +} +function hasRuntimeAdjustedExactReply(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); + const coverageStatus = toNonEmptyString(truthGate?.coverage_status); + const groundingStatus = toNonEmptyString(truthGate?.grounding_status); + const hasFullConfirmedTruth = truthGateStatus === "full_confirmed" || + sourceTruthGateStatus === "full_confirmed" || + (coverageStatus === "full" && groundingStatus === "grounded"); + if (!hasFullConfirmedTruth) { + return false; + } + const truthAnswerShape = readTruthAnswerShape(input); + const capabilityContractId = toNonEmptyString(truthAnswerShape?.capability_contract_id); + if (!capabilityContractId) { + return false; + } + return readStateTransitionReasonCodes(input).some((reason) => /^intent_adjusted_to_.+_followup_context$/i.test(reason)); +} function hasAlignedFactualAddressReply(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; } - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { return false; } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); @@ -152,7 +204,10 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; } - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (hasRuntimeAdjustedExactReply(input, entryPoint)) { return false; } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); @@ -166,7 +221,7 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) { return !isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning); } function hasMatchedFactualAddressContinuationTarget(input, entryPoint) { - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { return false; } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { @@ -182,7 +237,7 @@ function hasFullConfirmedFactualAddressReply(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; } - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { return false; } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { @@ -214,6 +269,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const semanticConflictWithDiscoveryTurnMeaning = hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint); const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); + const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); } @@ -241,6 +297,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } + if (runtimeAdjustedExactReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning"); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate"); } @@ -261,6 +320,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !alignedFactualAddressReply && !matchedFactualAddressContinuationTarget && !fullConfirmedFactualAddressReply && + !runtimeAdjustedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 97697c3..d12aa57 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -241,12 +241,15 @@ function collectFollowupDiscoverySeed(followupContext) { ? mapPilotScopeToFollowupMeaning(pilotScope) : mapAddressIntentToFollowupMeaning(previousIntent); const discoveryEntities = collectEntityCandidates(followupContext?.previous_discovery_entity_candidates); + const entityResolutionStatus = toNonEmptyString(followupContext?.previous_discovery_entity_resolution_status); + const entityResolutionAmbiguityCandidates = collectEntityCandidates(followupContext?.previous_discovery_entity_ambiguity_candidates); + const ambiguityBlocksImplicitGrounding = pilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous"; const counterparty = toNonEmptyString(previousFilters?.counterparty) ?? toNonEmptyString(rootFilters?.counterparty) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" ? toNonEmptyString(followupContext?.previous_anchor_value) : null) ?? - (discoveryEntities[0] ?? null); + (ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null); const organization = toNonEmptyString(previousFilters?.organization) ?? toNonEmptyString(rootFilters?.organization) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "organization" @@ -260,7 +263,9 @@ function collectFollowupDiscoverySeed(followupContext) { action: mapped.action, unsupported: mapped.unsupported, counterparty, - discoveryEntity: discoveryEntities[0] ?? null, + discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, + entityResolutionStatus, + entityResolutionAmbiguityCandidates, organization, dateScope, metadataRouteFamily: toNonEmptyString(followupContext?.previous_discovery_metadata_route_family), @@ -350,6 +355,93 @@ function rawEntityResolutionCandidate(text) { } return null; } +function resolveEntityResolutionAmbiguityChoice(text, candidates) { + const normalizedText = canonicalizeEntityResolutionCandidate(text); + if (!normalizedText || candidates.length <= 0) { + return null; + } + const exactMatch = candidates.find((candidate) => canonicalizeEntityResolutionCandidate(candidate) === normalizedText); + if (exactMatch) { + return exactMatch; + } + const includedMatches = candidates.filter((candidate) => { + const normalizedCandidate = canonicalizeEntityResolutionCandidate(candidate); + return normalizedCandidate.length > 0 && normalizedText.includes(normalizedCandidate); + }); + if (includedMatches.length === 1) { + return includedMatches[0]; + } + const narrowedMatches = candidates.filter((candidate) => { + const normalizedCandidate = canonicalizeEntityResolutionCandidate(candidate); + return normalizedText.length >= 4 && normalizedCandidate.includes(normalizedText); + }); + if (narrowedMatches.length === 1) { + return narrowedMatches[0]; + } + const normalizedLowerText = compactLower(text); + const ordinalMatchers = [ + { + index: 0, + keywords: ["первый"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*1(?:$|[^\p{L}\p{N}])/iu, + /^\s*1\s*$/u + ] + }, + { + index: 1, + keywords: ["второй"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*2(?:$|[^\p{L}\p{N}])/iu, + /^\s*2\s*$/u + ] + }, + { + index: 2, + keywords: ["третий"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*3(?:$|[^\p{L}\p{N}])/iu, + /^\s*3\s*$/u + ] + }, + { + index: 3, + keywords: ["четвертый", "четвёртый"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*4(?:$|[^\p{L}\p{N}])/iu, + /^\s*4\s*$/u + ] + }, + { + index: 4, + keywords: ["пятый"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*5(?:$|[^\p{L}\p{N}])/iu, + /^\s*5\s*$/u + ] + }, + { + index: 5, + keywords: ["шестой"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*6(?:$|[^\p{L}\p{N}])/iu, + /^\s*6\s*$/u + ] + } + ]; + for (const matcher of ordinalMatchers) { + if (matcher.index < candidates.length && + (matcher.keywords.some((keyword) => normalizedLowerText.includes(keyword)) || + matcher.numericPatterns.some((pattern) => pattern.test(normalizedLowerText)))) { + return candidates[matcher.index]; + } + } + if (candidates.length > 0 && + (normalizedLowerText.includes("последний") || normalizedLowerText.includes("крайний"))) { + return candidates[candidates.length - 1] ?? null; + } + return null; +} function metadataActionFromRawText(text) { if (/(?:\u043e\u0431\u044a\u0435\u043a\u0442(?:\u044b|\u0430|\u043e\u0432)?|objects?)/iu.test(text)) { return "inspect_surface"; @@ -468,7 +560,11 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const rawDateScope = collectDateScopeFromRawText(rawText); const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; - const entityResolutionSignal = rawEntityResolutionSignal || Boolean(rawEntityCandidate); + const entityResolutionClarificationCandidate = followupSeed.pilotScope === "entity_resolution_search_v1" && + followupSeed.entityResolutionStatus === "ambiguous" + ? resolveEntityResolutionAmbiguityChoice(rawEntitySourceText, followupSeed.entityResolutionAmbiguityCandidates) + : null; + const entityResolutionSignal = rawEntityResolutionSignal || Boolean(rawEntityCandidate) || Boolean(entityResolutionClarificationCandidate); const metadataDocumentHintSignal = hasDocumentEvidenceFollowupSignal(rawText) || hasPronounDocumentEvidenceFollowupSignal(rawText); const metadataMovementHintSignal = hasMovementEvidenceFollowupSignal(rawText) || hasPronounMovementEvidenceFollowupSignal(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); @@ -521,12 +617,26 @@ function buildAssistantMcpDiscoveryTurnInput(input) { !rawValueFlowSignal && !rawMetadataSignal && metadataDocumentHintSignal); + const entityResolutionClarifiedDocumentFollowupApplicable = Boolean(followupSeed.pilotScope === "entity_resolution_search_v1" && + followupSeed.entityResolutionStatus === "ambiguous" && + entityResolutionClarificationCandidate && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataDocumentHintSignal); const entityResolutionGroundedMovementFollowupApplicable = Boolean(followupSeed.pilotScope === "entity_resolution_search_v1" && (followupSeed.counterparty || followupSeed.discoveryEntity) && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && metadataMovementHintSignal); + const entityResolutionClarifiedMovementFollowupApplicable = Boolean(followupSeed.pilotScope === "entity_resolution_search_v1" && + followupSeed.entityResolutionStatus === "ambiguous" && + entityResolutionClarificationCandidate && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataMovementHintSignal); const groundedValueFlowFollowupApplicable = Boolean(rawValueFlowSignal && !rawLifecycleSignal && !rawMetadataSignal && @@ -607,6 +717,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const metadataGroundedDocumentLaneApplicable = metadataGroundedDocumentFollowupApplicable || metadataAmbiguityResolvedDocumentFollowupApplicable || entityResolutionGroundedDocumentFollowupApplicable || + entityResolutionClarifiedDocumentFollowupApplicable || valueFlowGroundedDocumentFollowupApplicable || movementEvidenceGroundedDocumentFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "document_evidence") || @@ -614,6 +725,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const metadataGroundedMovementLaneApplicable = metadataGroundedMovementFollowupApplicable || metadataAmbiguityResolvedMovementFollowupApplicable || entityResolutionGroundedMovementFollowupApplicable || + entityResolutionClarifiedMovementFollowupApplicable || valueFlowGroundedMovementFollowupApplicable || documentEvidenceGroundedMovementFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "movement_evidence") || @@ -667,11 +779,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, - entityResolutionSignal + entityResolutionSignal: entityResolutionSignal && + !metadataGroundedDocumentLaneApplicable && + !metadataGroundedMovementLaneApplicable }); const groundedFollowupEntity = followupSeed.counterparty ?? followupSeed.discoveryEntity; const entityCandidates = entityResolutionSignal ? [] : []; if (entityResolutionSignal) { + pushNormalizedEntityResolutionCandidate(entityCandidates, entityResolutionClarificationCandidate); pushNormalizedEntityResolutionCandidate(entityCandidates, rawEntityCandidate); for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { pushNormalizedEntityResolutionCandidate(entityCandidates, candidate); @@ -814,12 +929,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { semanticDataNeed, explicitIntentCandidate, followupDiscoverySeedApplicable: followupDiscoverySeedApplicable || + Boolean(entityResolutionClarificationCandidate) || effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || groundedValueFlowFollowupApplicable, - forceDiscoveryOverExplicitIntent: metadataAmbiguityLaneClarificationApplicable || + forceDiscoveryOverExplicitIntent: Boolean(entityResolutionClarificationCandidate) || + metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || groundedValueFlowFollowupApplicable @@ -827,7 +944,10 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal = assistantTurnMeaning ? "assistant_turn_meaning" - : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable + : followupDiscoverySeedApplicable || + Boolean(entityResolutionClarificationCandidate) || + effectiveMetadataFollowupSeedApplicable || + metadataAmbiguityLaneClarificationApplicable ? "followup_context" : metadataGroundedMovementLaneApplicable ? "followup_context" @@ -862,6 +982,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (rawEntityCandidate) { pushReason(reasonCodes, "mcp_discovery_entity_scope_from_raw_entity_search"); } + if (entityResolutionClarificationCandidate) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarification_candidate_selected"); + } if (payoutSignal) { pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); } @@ -892,9 +1015,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (entityResolutionGroundedDocumentFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_document_followup"); } + if (entityResolutionClarifiedDocumentFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarified_document_followup"); + } if (entityResolutionGroundedMovementFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_movement_followup"); } + if (entityResolutionClarifiedMovementFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarified_movement_followup"); + } if (valueFlowGroundedDocumentFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_value_flow_grounded_document_followup"); } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 3961e9a..ccce7d8 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -503,7 +503,9 @@ function createAssistantTransitionPolicy(deps) { const sourceDiscoveryMetadataSelectedEntitySet = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataSelectedEntitySet)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryMetadataAmbiguityDetected = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityDetected)(carryoverSourceDebug); const sourceDiscoveryMetadataAmbiguityEntitySets = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityEntitySets)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryEntityResolutionStatus = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityResolutionStatus)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryEntityAmbiguityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityAmbiguityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent; @@ -738,7 +740,11 @@ function createAssistantTransitionPolicy(deps) { previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, + previous_discovery_entity_resolution_status: sourceDiscoveryEntityResolutionStatus ?? undefined, previous_discovery_entity_candidates: sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, + previous_discovery_entity_ambiguity_candidates: sourceDiscoveryEntityAmbiguityCandidates.length > 0 + ? sourceDiscoveryEntityAmbiguityCandidates + : undefined, previous_discovery_metadata_route_family: sourceDiscoveryMetadataRouteFamily ?? undefined, previous_discovery_metadata_selected_entity_set: sourceDiscoveryMetadataSelectedEntitySet ?? undefined, previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 6a35116..dfaa1d1 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -40,6 +40,8 @@ export interface AddressFollowupContext { | "unknown" | null; previous_anchor_value?: string | null; + previous_discovery_entity_resolution_status?: "resolved" | "ambiguous" | "not_found" | null; + previous_discovery_entity_ambiguity_candidates?: string[]; resolved_counterparty_from_display?: boolean; root_intent?: AddressIntent; root_filters?: AddressFilterSet; diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 1643679..de6b2aa 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -182,6 +182,24 @@ function readAssistantMcpDiscoveryDerivedEntityResolution( return toRecordObject(pilot?.derived_entity_resolution); } +export function readAssistantMcpDiscoveryEntityResolutionStatus( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryDerivedEntityResolution(debug)?.resolution_status); +} + +export function readAssistantMcpDiscoveryEntityAmbiguityCandidates( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string[] { + const values = readAssistantMcpDiscoveryDerivedEntityResolution(debug)?.ambiguity_candidates; + if (!Array.isArray(values)) { + return []; + } + return values.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)); +} + function collectAssistantMcpDiscoveryEntityCandidates( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 4d80c98..39419b3 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -52,6 +52,13 @@ function uniqueStrings(values: string[]): string[] { return result; } +function formatNamedChoiceList(values: string[]): string { + return uniqueStrings(values) + .slice(0, 6) + .map((value, index) => `${index + 1}. ${value}`) + .join("; "); +} + function isInternalMechanicsLine(value: string): boolean { const text = value.toLowerCase(); return ( @@ -344,6 +351,12 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { if (isEntityResolutionPilot(pilot) && mode === "needs_clarification") { + const ambiguityCandidates = pilot.derived_entity_resolution?.ambiguity_candidates ?? []; + if (ambiguityCandidates.length > 0) { + return `Уточните, какой именно контрагент нужен: ${formatNamedChoiceList( + ambiguityCandidates + )}. Можно ответить названием или номером варианта.`; + } return "Уточните точное название контрагента или добавьте ИНН, и я продолжу уже по нужной сущности в 1С."; } if (isEntityResolutionPilot(pilot) && mode === "confirmed_with_bounded_inference") { @@ -536,7 +549,9 @@ function derivedEntityResolutionInferenceLine(pilot: AssistantMcpDiscoveryPilotE return "Сейчас подтверждено только заземление сущности по каталогу 1С; документы, движения и денежные показатели по ней еще не проверялись."; } if (resolution.resolution_status === "ambiguous" && resolution.ambiguity_candidates.length > 0) { - return `В checked catalog slice есть несколько близких кандидатов: ${resolution.ambiguity_candidates.join(", ")}. Без уточнения нельзя честно выбрать одного контрагента для следующего шага.`; + return `В каталоге 1С нашлось несколько близких кандидатов: ${formatNamedChoiceList( + resolution.ambiguity_candidates + )}. Без уточнения нельзя честно выбрать одного контрагента для следующего шага.`; } return null; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index 07cf8a5..ae196b2 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -974,6 +974,54 @@ function summarizeEntityResolutionRows(result: AddressMcpQueryExecutorResult): s return `${result.fetched_rows} MCP catalog rows fetched for entity search`; } +function entityResolutionFollowupStepLimitation(): string { + return "Entity-resolution could not continue because the checked catalog search step did not return a confirmed slice"; +} + +function buildEntityResolutionResolveProbeResult(input: { + queryResult: AddressMcpQueryExecutorResult; + resolution: AssistantMcpDiscoveryDerivedEntityResolution | null; +}): AssistantMcpDiscoveryProbeResult { + if (!input.resolution) { + return { + primitive_id: "resolve_entity_reference", + status: "ok", + rows_received: input.queryResult.fetched_rows, + rows_matched: 0, + limitation: null + }; + } + if (input.resolution.resolution_status === "resolved") { + return { + primitive_id: "resolve_entity_reference", + status: "ok", + rows_received: input.queryResult.fetched_rows, + rows_matched: 1, + limitation: null + }; + } + return { + primitive_id: "resolve_entity_reference", + status: "ok", + rows_received: input.queryResult.fetched_rows, + rows_matched: 0, + limitation: null + }; +} + +function buildEntityResolutionCoverageProbeResult(input: { + resolution: AssistantMcpDiscoveryDerivedEntityResolution | null; +}): AssistantMcpDiscoveryProbeResult { + const resolved = input.resolution?.resolution_status === "resolved"; + return { + primitive_id: "probe_coverage", + status: "ok", + rows_received: 1, + rows_matched: resolved ? 1 : 0, + limitation: null + }; +} + function metadataRowText(row: Record, keys: string[]): string | null { for (const key of keys) { const text = toNonEmptyString(row[key]); @@ -2153,31 +2201,88 @@ export async function executeAssistantMcpDiscoveryPilot( }; } + let derivedEntityResolution: AssistantMcpDiscoveryDerivedEntityResolution | null = null; for (const step of dryRun.execution_steps) { - if (step.primitive_id !== "search_business_entity") { - skippedPrimitives.push(step.primitive_id); - probeResults.push(skippedProbeResult(step, "pilot_only_executes_search_business_entity")); + if (step.primitive_id === "search_business_entity") { + queryResult = await runtimeDeps.executeAddressMcpQuery({ + query: ENTITY_RESOLUTION_COUNTERPARTY_QUERY_TEMPLATE.replaceAll( + "__LIMIT__", + String(ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT) + ), + limit: ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT + }); + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); + if (queryResult.error) { + pushUnique(queryLimitations, queryResult.error); + pushReason(reasonCodes, "pilot_search_business_entity_mcp_error"); + } else { + pushReason(reasonCodes, "pilot_search_business_entity_mcp_executed"); + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } continue; } - queryResult = await runtimeDeps.executeAddressMcpQuery({ - query: ENTITY_RESOLUTION_COUNTERPARTY_QUERY_TEMPLATE.replaceAll( - "__LIMIT__", - String(ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT) - ), - limit: ENTITY_RESOLUTION_COUNTERPARTY_LOOKUP_LIMIT - }); - pushUnique(executedPrimitives, step.primitive_id); - probeResults.push(queryResultToProbeResult(step.primitive_id, queryResult)); - if (queryResult.error) { - pushUnique(queryLimitations, queryResult.error); - pushReason(reasonCodes, "pilot_search_business_entity_mcp_error"); - } else { - pushReason(reasonCodes, "pilot_search_business_entity_mcp_executed"); + + if (step.primitive_id === "resolve_entity_reference") { + if (!queryResult || queryResult.error) { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, entityResolutionFollowupStepLimitation())); + continue; + } + if (!derivedEntityResolution) { + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push( + buildEntityResolutionResolveProbeResult({ + queryResult, + resolution: derivedEntityResolution + }) + ); + if (derivedEntityResolution?.resolution_status === "resolved") { + pushReason(reasonCodes, "pilot_resolve_entity_reference_from_catalog_rows"); + } else if (derivedEntityResolution?.resolution_status === "ambiguous") { + pushReason(reasonCodes, "pilot_resolve_entity_reference_requires_clarification"); + } else { + pushReason(reasonCodes, "pilot_resolve_entity_reference_not_confirmed"); + } + continue; } + + if (step.primitive_id === "probe_coverage") { + if (!queryResult || queryResult.error) { + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, entityResolutionFollowupStepLimitation())); + continue; + } + if (!derivedEntityResolution) { + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } + pushUnique(executedPrimitives, step.primitive_id); + probeResults.push( + buildEntityResolutionCoverageProbeResult({ + resolution: derivedEntityResolution + }) + ); + pushReason(reasonCodes, "pilot_probe_coverage_executed_for_entity_resolution"); + if (derivedEntityResolution?.resolution_status === "resolved") { + pushReason(reasonCodes, "pilot_entity_resolution_grounding_stable_for_downstream_probe"); + } else if (derivedEntityResolution?.resolution_status === "ambiguous") { + pushReason(reasonCodes, "pilot_entity_resolution_coverage_requires_clarification"); + } else { + pushReason(reasonCodes, "pilot_entity_resolution_coverage_not_confirmed"); + } + continue; + } + + skippedPrimitives.push(step.primitive_id); + probeResults.push(skippedProbeResult(step, "pilot_entity_resolution_step_not_implemented")); } const sourceRowsSummary = queryResult ? summarizeEntityResolutionRows(queryResult) : null; - const derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + if (!derivedEntityResolution && queryResult && !queryResult.error) { + derivedEntityResolution = deriveEntityResolution(queryResult, requestedEntity); + } if (derivedEntityResolution?.resolution_status === "resolved") { pushReason(reasonCodes, "pilot_derived_entity_resolution_from_catalog_rows"); } @@ -2195,7 +2300,7 @@ export async function executeAssistantMcpDiscoveryPilot( unknownFacts: buildEntityResolutionUnknownFacts(derivedEntityResolution, requestedEntity), sourceRowsSummary, queryLimitations, - recommendedNextProbe: "resolve_entity_reference" + recommendedNextProbe: null }); return { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 414a742..c1bd30d 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -214,6 +214,68 @@ function readDiscoveryTurnMeaning( return toRecordObject(turnInput?.turn_meaning_ref); } +function readTruthAnswerShape(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): Record | null { + const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); + if (directShape) { + return directShape; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + return toRecordObject(truthAnswerPolicy?.answer_shape); +} + +function hasEffectivelyFactualAddressReply(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { + if (toNonEmptyString(input.currentReplyType) === "factual") { + return true; + } + const truthAnswerShape = readTruthAnswerShape(input); + return toNonEmptyString(truthAnswerShape?.reply_type) === "factual"; +} + +function readStateTransitionReasonCodes(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): string[] { + const directTransition = toRecordObject(input.addressRuntimeMeta?.assistant_state_transition_v1); + const fallbackTransition = toRecordObject(input.addressRuntimeMeta?.state_transition_contract); + const stateTransition = directTransition ?? fallbackTransition; + if (!stateTransition || !Array.isArray(stateTransition.reason_codes)) { + return []; + } + return stateTransition.reason_codes + .map((item) => toNonEmptyString(item)) + .filter((item): item is string => Boolean(item)); +} + +function hasRuntimeAdjustedExactReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); + const coverageStatus = toNonEmptyString(truthGate?.coverage_status); + const groundingStatus = toNonEmptyString(truthGate?.grounding_status); + const hasFullConfirmedTruth = + truthGateStatus === "full_confirmed" || + sourceTruthGateStatus === "full_confirmed" || + (coverageStatus === "full" && groundingStatus === "grounded"); + if (!hasFullConfirmedTruth) { + return false; + } + const truthAnswerShape = readTruthAnswerShape(input); + const capabilityContractId = toNonEmptyString(truthAnswerShape?.capability_contract_id); + if (!capabilityContractId) { + return false; + } + return readStateTransitionReasonCodes(input).some( + (reason) => /^intent_adjusted_to_.+_followup_context$/i.test(reason) + ); +} + function hasAlignedFactualAddressReply( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -221,7 +283,7 @@ function hasAlignedFactualAddressReply( if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; } - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { return false; } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); @@ -235,7 +297,10 @@ function hasSemanticConflictWithDiscoveryTurnMeaning( if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; } - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (hasRuntimeAdjustedExactReply(input, entryPoint)) { return false; } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); @@ -253,7 +318,7 @@ function hasMatchedFactualAddressContinuationTarget( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null ): boolean { - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { return false; } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { @@ -274,7 +339,7 @@ function hasFullConfirmedFactualAddressReply( if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; } - if (toNonEmptyString(input.currentReplyType) !== "factual") { + if (!hasEffectivelyFactualAddressReply(input)) { return false; } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { @@ -310,6 +375,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const semanticConflictWithDiscoveryTurnMeaning = hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint); const matchedFactualAddressContinuationTarget = hasMatchedFactualAddressContinuationTarget(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); + const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); @@ -338,6 +404,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } + if (runtimeAdjustedExactReply) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning" + ); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason( reasonCodes, @@ -363,6 +435,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !alignedFactualAddressReply && !matchedFactualAddressContinuationTarget && !fullConfirmedFactualAddressReply && + !runtimeAdjustedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 734561c..4e842b5 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -306,6 +306,8 @@ function collectFollowupDiscoverySeed(followupContext: Record | unsupported: string | null; counterparty: string | null; discoveryEntity: string | null; + entityResolutionStatus: string | null; + entityResolutionAmbiguityCandidates: string[]; organization: string | null; dateScope: string | null; metadataRouteFamily: string | null; @@ -323,13 +325,19 @@ function collectFollowupDiscoverySeed(followupContext: Record | ? mapPilotScopeToFollowupMeaning(pilotScope) : mapAddressIntentToFollowupMeaning(previousIntent); const discoveryEntities = collectEntityCandidates(followupContext?.previous_discovery_entity_candidates); + const entityResolutionStatus = toNonEmptyString(followupContext?.previous_discovery_entity_resolution_status); + const entityResolutionAmbiguityCandidates = collectEntityCandidates( + followupContext?.previous_discovery_entity_ambiguity_candidates + ); + const ambiguityBlocksImplicitGrounding = + pilotScope === "entity_resolution_search_v1" && entityResolutionStatus === "ambiguous"; const counterparty = toNonEmptyString(previousFilters?.counterparty) ?? toNonEmptyString(rootFilters?.counterparty) ?? (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" ? toNonEmptyString(followupContext?.previous_anchor_value) : null) ?? - (discoveryEntities[0] ?? null); + (ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null); const organization = toNonEmptyString(previousFilters?.organization) ?? toNonEmptyString(rootFilters?.organization) ?? @@ -345,7 +353,9 @@ function collectFollowupDiscoverySeed(followupContext: Record | action: mapped.action, unsupported: mapped.unsupported, counterparty, - discoveryEntity: discoveryEntities[0] ?? null, + discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, + entityResolutionStatus, + entityResolutionAmbiguityCandidates, organization, dateScope, metadataRouteFamily: toNonEmptyString(followupContext?.previous_discovery_metadata_route_family), @@ -492,6 +502,105 @@ function rawEntityResolutionCandidate(text: string): string | null { return null; } +function resolveEntityResolutionAmbiguityChoice(text: string, candidates: string[]): string | null { + const normalizedText = canonicalizeEntityResolutionCandidate(text); + if (!normalizedText || candidates.length <= 0) { + return null; + } + const exactMatch = candidates.find( + (candidate) => canonicalizeEntityResolutionCandidate(candidate) === normalizedText + ); + if (exactMatch) { + return exactMatch; + } + + const includedMatches = candidates.filter((candidate) => { + const normalizedCandidate = canonicalizeEntityResolutionCandidate(candidate); + return normalizedCandidate.length > 0 && normalizedText.includes(normalizedCandidate); + }); + if (includedMatches.length === 1) { + return includedMatches[0]; + } + + const narrowedMatches = candidates.filter((candidate) => { + const normalizedCandidate = canonicalizeEntityResolutionCandidate(candidate); + return normalizedText.length >= 4 && normalizedCandidate.includes(normalizedText); + }); + if (narrowedMatches.length === 1) { + return narrowedMatches[0]; + } + + const normalizedLowerText = compactLower(text); + const ordinalMatchers: Array<{ index: number; keywords: string[]; numericPatterns: RegExp[] }> = [ + { + index: 0, + keywords: ["первый"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*1(?:$|[^\p{L}\p{N}])/iu, + /^\s*1\s*$/u + ] + }, + { + index: 1, + keywords: ["второй"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*2(?:$|[^\p{L}\p{N}])/iu, + /^\s*2\s*$/u + ] + }, + { + index: 2, + keywords: ["третий"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*3(?:$|[^\p{L}\p{N}])/iu, + /^\s*3\s*$/u + ] + }, + { + index: 3, + keywords: ["четвертый", "четвёртый"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*4(?:$|[^\p{L}\p{N}])/iu, + /^\s*4\s*$/u + ] + }, + { + index: 4, + keywords: ["пятый"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*5(?:$|[^\p{L}\p{N}])/iu, + /^\s*5\s*$/u + ] + }, + { + index: 5, + keywords: ["шестой"], + numericPatterns: [ + /(?:^|[^\p{L}\p{N}])(?:вариант|номер|№)?\s*6(?:$|[^\p{L}\p{N}])/iu, + /^\s*6\s*$/u + ] + } + ]; + for (const matcher of ordinalMatchers) { + if ( + matcher.index < candidates.length && + (matcher.keywords.some((keyword) => normalizedLowerText.includes(keyword)) || + matcher.numericPatterns.some((pattern) => pattern.test(normalizedLowerText))) + ) { + return candidates[matcher.index]; + } + } + + if ( + candidates.length > 0 && + (normalizedLowerText.includes("последний") || normalizedLowerText.includes("крайний")) + ) { + return candidates[candidates.length - 1] ?? null; + } + + return null; +} + function metadataActionFromRawText(text: string): string { if (/(?:\u043e\u0431\u044a\u0435\u043a\u0442(?:\u044b|\u0430|\u043e\u0432)?|objects?)/iu.test(text)) { return "inspect_surface"; @@ -642,7 +751,16 @@ export function buildAssistantMcpDiscoveryTurnInput( const rawDateScope = collectDateScopeFromRawText(rawText); const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; - const entityResolutionSignal = rawEntityResolutionSignal || Boolean(rawEntityCandidate); + const entityResolutionClarificationCandidate = + followupSeed.pilotScope === "entity_resolution_search_v1" && + followupSeed.entityResolutionStatus === "ambiguous" + ? resolveEntityResolutionAmbiguityChoice( + rawEntitySourceText, + followupSeed.entityResolutionAmbiguityCandidates + ) + : null; + const entityResolutionSignal = + rawEntityResolutionSignal || Boolean(rawEntityCandidate) || Boolean(entityResolutionClarificationCandidate); const metadataDocumentHintSignal = hasDocumentEvidenceFollowupSignal(rawText) || hasPronounDocumentEvidenceFollowupSignal(rawText); const metadataMovementHintSignal = @@ -712,6 +830,15 @@ export function buildAssistantMcpDiscoveryTurnInput( !rawMetadataSignal && metadataDocumentHintSignal ); + const entityResolutionClarifiedDocumentFollowupApplicable = Boolean( + followupSeed.pilotScope === "entity_resolution_search_v1" && + followupSeed.entityResolutionStatus === "ambiguous" && + entityResolutionClarificationCandidate && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataDocumentHintSignal + ); const entityResolutionGroundedMovementFollowupApplicable = Boolean( followupSeed.pilotScope === "entity_resolution_search_v1" && (followupSeed.counterparty || followupSeed.discoveryEntity) && @@ -720,6 +847,15 @@ export function buildAssistantMcpDiscoveryTurnInput( !rawMetadataSignal && metadataMovementHintSignal ); + const entityResolutionClarifiedMovementFollowupApplicable = Boolean( + followupSeed.pilotScope === "entity_resolution_search_v1" && + followupSeed.entityResolutionStatus === "ambiguous" && + entityResolutionClarificationCandidate && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal && + metadataMovementHintSignal + ); const groundedValueFlowFollowupApplicable = Boolean( rawValueFlowSignal && !rawLifecycleSignal && @@ -821,6 +957,7 @@ export function buildAssistantMcpDiscoveryTurnInput( metadataGroundedDocumentFollowupApplicable || metadataAmbiguityResolvedDocumentFollowupApplicable || entityResolutionGroundedDocumentFollowupApplicable || + entityResolutionClarifiedDocumentFollowupApplicable || valueFlowGroundedDocumentFollowupApplicable || movementEvidenceGroundedDocumentFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "document_evidence") || @@ -829,6 +966,7 @@ export function buildAssistantMcpDiscoveryTurnInput( metadataGroundedMovementFollowupApplicable || metadataAmbiguityResolvedMovementFollowupApplicable || entityResolutionGroundedMovementFollowupApplicable || + entityResolutionClarifiedMovementFollowupApplicable || valueFlowGroundedMovementFollowupApplicable || documentEvidenceGroundedMovementFollowupApplicable || (metadataGroundedLaneContinuationApplicable && followupSeed.metadataRouteFamily === "movement_evidence") || @@ -887,11 +1025,15 @@ export function buildAssistantMcpDiscoveryTurnInput( lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, - entityResolutionSignal + entityResolutionSignal: + entityResolutionSignal && + !metadataGroundedDocumentLaneApplicable && + !metadataGroundedMovementLaneApplicable }); const groundedFollowupEntity = followupSeed.counterparty ?? followupSeed.discoveryEntity; const entityCandidates = entityResolutionSignal ? [] : []; if (entityResolutionSignal) { + pushNormalizedEntityResolutionCandidate(entityCandidates, entityResolutionClarificationCandidate); pushNormalizedEntityResolutionCandidate(entityCandidates, rawEntityCandidate); for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { pushNormalizedEntityResolutionCandidate(entityCandidates, candidate); @@ -1043,12 +1185,14 @@ export function buildAssistantMcpDiscoveryTurnInput( explicitIntentCandidate, followupDiscoverySeedApplicable: followupDiscoverySeedApplicable || + Boolean(entityResolutionClarificationCandidate) || effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || groundedValueFlowFollowupApplicable, forceDiscoveryOverExplicitIntent: + Boolean(entityResolutionClarificationCandidate) || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || @@ -1057,7 +1201,10 @@ export function buildAssistantMcpDiscoveryTurnInput( const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal: AssistantMcpDiscoveryTurnInputSource = assistantTurnMeaning ? "assistant_turn_meaning" - : followupDiscoverySeedApplicable || effectiveMetadataFollowupSeedApplicable || metadataAmbiguityLaneClarificationApplicable + : followupDiscoverySeedApplicable || + Boolean(entityResolutionClarificationCandidate) || + effectiveMetadataFollowupSeedApplicable || + metadataAmbiguityLaneClarificationApplicable ? "followup_context" : metadataGroundedMovementLaneApplicable ? "followup_context" @@ -1093,6 +1240,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (rawEntityCandidate) { pushReason(reasonCodes, "mcp_discovery_entity_scope_from_raw_entity_search"); } + if (entityResolutionClarificationCandidate) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarification_candidate_selected"); + } if (payoutSignal) { pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); } @@ -1123,9 +1273,15 @@ export function buildAssistantMcpDiscoveryTurnInput( if (entityResolutionGroundedDocumentFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_document_followup"); } + if (entityResolutionClarifiedDocumentFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarified_document_followup"); + } if (entityResolutionGroundedMovementFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_grounded_movement_followup"); } + if (entityResolutionClarifiedMovementFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarified_movement_followup"); + } if (valueFlowGroundedDocumentFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_value_flow_grounded_document_followup"); } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index b4390fd..9f92215 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -13,6 +13,8 @@ import { readAssistantMcpDiscoveryMetadataAmbiguityDetected, readAssistantMcpDiscoveryMetadataAmbiguityEntitySets, readAssistantMcpDiscoveryEntityCandidates, + readAssistantMcpDiscoveryEntityAmbiguityCandidates, + readAssistantMcpDiscoveryEntityResolutionStatus, readAssistantMcpDiscoveryMetadataRouteFamily, readAssistantMcpDiscoveryMetadataSelectedEntitySet, readAddressDebugTemporalScope, @@ -673,10 +675,18 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryEntityResolutionStatus = readAssistantMcpDiscoveryEntityResolutionStatus( + carryoverSourceDebug, + deps.toNonEmptyString + ); const sourceDiscoveryEntityCandidates = readAssistantMcpDiscoveryEntityCandidates( carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryEntityAmbiguityCandidates = readAssistantMcpDiscoveryEntityAmbiguityCandidates( + carryoverSourceDebug, + deps.toNonEmptyString + ); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; @@ -1013,8 +1023,13 @@ export function createAssistantTransitionPolicy(deps) { previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, + previous_discovery_entity_resolution_status: sourceDiscoveryEntityResolutionStatus ?? undefined, previous_discovery_entity_candidates: sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, + previous_discovery_entity_ambiguity_candidates: + sourceDiscoveryEntityAmbiguityCandidates.length > 0 + ? sourceDiscoveryEntityAmbiguityCandidates + : undefined, previous_discovery_metadata_route_family: sourceDiscoveryMetadataRouteFamily ?? undefined, previous_discovery_metadata_selected_entity_set: sourceDiscoveryMetadataSelectedEntitySet ?? undefined, previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 7faaf0c..2c32da5 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -316,7 +316,12 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.answer_mode).toBe("needs_clarification"); expect(draft.headline).toContain("несколько похожих контрагентов"); expect(draft.inference_lines.join("\n")).toContain("СВК-А"); - expect(draft.next_step_line).toContain("точное название контрагента"); + expect(draft.inference_lines.join("\n")).toContain("1. СВК-А"); + expect(draft.inference_lines.join("\n")).toContain("2. СВК-Б"); + expect(draft.next_step_line).toContain("какой именно контрагент нужен"); + expect(draft.next_step_line).toContain("1. СВК-А"); + expect(draft.next_step_line).toContain("2. СВК-Б"); + expect(draft.next_step_line).toContain("номером варианта"); }); it.skip("keeps entity search honest when no catalog candidate was confirmed", async () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index ee9833e..2a8f437 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -249,7 +249,7 @@ describe("assistant MCP discovery pilot executor", () => { }); }); - it.skip("executes entity-resolution search through the checked counterparty catalog slice", async () => { + it("executes the full entity-resolution chain through the checked counterparty catalog slice", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { asked_domain_family: "entity_resolution", @@ -268,8 +268,12 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.pilot_status).toBe("executed"); expect(result.pilot_scope).toBe("entity_resolution_search_v1"); expect(result.mcp_execution_performed).toBe(true); - expect(result.executed_primitives).toEqual(["search_business_entity"]); - expect(result.skipped_primitives).toEqual(["resolve_entity_reference", "probe_coverage"]); + expect(result.executed_primitives).toEqual([ + "search_business_entity", + "resolve_entity_reference", + "probe_coverage" + ]); + expect(result.skipped_primitives).toEqual([]); expect(result.derived_entity_resolution).toMatchObject({ requested_entity: "Группа СВК", resolution_status: "resolved", @@ -279,20 +283,23 @@ describe("assistant MCP discovery pilot executor", () => { inference_basis: "catalog_counterparty_search_rows" }); expect(result.evidence.confirmed_facts).toContain( - "A matching 1C counterparty was found in the checked catalog slice: Группа СВК" + "В проверенном каталожном срезе 1С найден контрагент: Группа СВК" ); expect(result.evidence.inferred_facts).toContain( - "Only catalog-level entity grounding was checked so far; no business rows were executed yet" + "Пока проверено только заземление сущности по каталогу 1С; документы, движения и денежные показатели еще не проверялись" ); expect(result.evidence.unknown_facts).toContain( - "No business documents, movements, or turnovers were checked yet; only catalog grounding was attempted" + "Документы, движения и денежные показатели по этому контрагенту еще не проверялись; пока был только каталожный поиск" ); expect(result.reason_codes).toContain("pilot_search_business_entity_mcp_executed"); + expect(result.reason_codes).toContain("pilot_resolve_entity_reference_from_catalog_rows"); + expect(result.reason_codes).toContain("pilot_probe_coverage_executed_for_entity_resolution"); + expect(result.reason_codes).toContain("pilot_entity_resolution_grounding_stable_for_downstream_probe"); expect(result.reason_codes).toContain("pilot_derived_entity_resolution_from_catalog_rows"); expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(1); }); - it.skip("keeps entity-resolution honest when several catalog candidates remain ambiguous", async () => { + it("keeps entity-resolution honest when several catalog candidates remain ambiguous", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { asked_domain_family: "entity_resolution", @@ -310,6 +317,12 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.pilot_status).toBe("executed"); expect(result.pilot_scope).toBe("entity_resolution_search_v1"); + expect(result.executed_primitives).toEqual([ + "search_business_entity", + "resolve_entity_reference", + "probe_coverage" + ]); + expect(result.skipped_primitives).toEqual([]); expect(result.derived_entity_resolution).toMatchObject({ requested_entity: "СВК", resolution_status: "ambiguous", @@ -319,8 +332,10 @@ describe("assistant MCP discovery pilot executor", () => { }); expect(result.evidence.confirmed_facts).toEqual([]); expect(result.evidence.unknown_facts).toContain( - "Exact 1C counterparty grounding remains ambiguous across: СВК-А, СВК-Б" + "Точное заземление контрагента в 1С остается неоднозначным между вариантами: СВК-А, СВК-Б" ); + expect(result.reason_codes).toContain("pilot_resolve_entity_reference_requires_clarification"); + expect(result.reason_codes).toContain("pilot_probe_coverage_executed_for_entity_resolution"); expect(result.reason_codes).toContain("pilot_entity_resolution_ambiguity_requires_clarification"); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index fe743e5..565158f 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -281,6 +281,75 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); }); + it("keeps runtime-adjusted exact VAT follow-up replies over stale discovery turn meaning", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Коротко: подтвержденный НДС к уплате за май 2017 — 0,00 руб.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "vat_liability_confirmed_for_tax_period", + truth_gate_contract_status: "full_confirmed", + assistant_truth_answer_policy_v1: { + truth_gate: { + coverage_status: "full", + grounding_status: "grounded", + source_truth_gate_status: "full_confirmed" + }, + answer_shape: { + reply_type: "factual", + capability_contract_id: "confirmed_vat_liability_for_tax_period" + } + }, + assistant_state_transition_v1: { + reason_codes: [ + "root_followup_continue_previous", + "intent_adjusted_to_vat_followup_context", + "period_from_from_followup_context", + "period_to_from_followup_context", + "confirmed_balance_exact_vat_tax_period_intent" + ] + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "confirmed_snapshot", + explicit_entity_candidates: ["ООО Альтернатива Плюс"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2017-05-31", + unsupported_but_understood_family: "counterparty_value_or_turnover" + } + }, + bridge: { + bridge_status: "checked_sources_only", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: false, + answer_draft: { + answer_mode: "checked_sources_only", + headline: "Я проверил доступный контур, но подтвержденного факта для ответа не получил.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: ["Полный объем входящих поступлений вне проверенного периода этим поиском не подтвержден."], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toContain("подтвержденный НДС к уплате"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + }); + it("keeps address lane answers when discovery was not requested for the current turn", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "supported exact route answer", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts index cf6274f..476eb88 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts @@ -112,4 +112,132 @@ describe("assistant MCP discovery runtime entry point", () => { expect(result.bridge?.pilot.pilot_scope).toBe("metadata_inspection_v1"); expect(result.bridge?.answer_draft.headline).toContain("метаданным 1С"); }); + + it("runs the bridge for raw entity-resolution wording and executes the full grounding chain", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "найди в 1С контрагента Группа СВК", + deps: buildDeps([ + { Counterparty: "Группа СВК", CounterpartyRef: "Ref-1" }, + { Counterparty: "СВК Логистика", CounterpartyRef: "Ref-2" } + ]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.semantic_data_need).toBe("entity discovery evidence"); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["Группа СВК"] + }); + expect(result.bridge?.bridge_status).toBe("answer_draft_ready"); + expect(result.bridge?.pilot.pilot_scope).toBe("entity_resolution_search_v1"); + expect(result.bridge?.pilot.executed_primitives).toEqual([ + "search_business_entity", + "resolve_entity_reference", + "probe_coverage" + ]); + expect(result.bridge?.pilot.derived_entity_resolution).toMatchObject({ + resolution_status: "resolved", + resolved_entity: "Группа СВК", + resolved_reference: "Ref-1" + }); + expect(result.bridge?.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference"); + }); + + it("runs the bridge again when the user clarifies one ambiguous entity-resolution candidate", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "СВК-А", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + }, + deps: buildDeps([ + { Counterparty: "СВК-А", CounterpartyRef: "Ref-1" }, + { Counterparty: "СВК-Б", CounterpartyRef: "Ref-2" } + ]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.source_signal).toBe("followup_context"); + expect(result.turn_input.semantic_data_need).toBe("entity discovery evidence"); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["СВК-А"] + }); + expect(result.bridge?.pilot.pilot_scope).toBe("entity_resolution_search_v1"); + expect(result.bridge?.pilot.executed_primitives).toEqual([ + "search_business_entity", + "resolve_entity_reference", + "probe_coverage" + ]); + expect(result.bridge?.pilot.derived_entity_resolution).toMatchObject({ + resolution_status: "resolved", + resolved_entity: "СВК-А", + resolved_reference: "Ref-1" + }); + }); + + it("chains an ordinal ambiguity clarification directly into value-flow execution", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "второй вариант, сколько получили за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + }, + deps: buildDeps([ + { Period: "2020-01-15T00:00:00", Amount: 1200, Counterparty: "СВК-Б" }, + { Period: "2020-02-20T00:00:00", Amount: 800, Counterparty: "СВК-Б" } + ]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.source_signal).toBe("followup_context"); + expect(result.turn_input.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["СВК-Б"], + explicit_date_scope: "2020" + }); + expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_value_flow_query_movements_v1"); + expect(result.bridge?.pilot.derived_value_flow).toMatchObject({ + counterparty: "СВК-Б", + period_scope: "2020", + total_amount: 2000 + }); + }); + + it("chains an ordinal ambiguity clarification directly into document evidence execution", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "первый вариант, покажи документы за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + }, + deps: buildDeps([{ Period: "2020-03-12T00:00:00", Counterparty: "СВК-А", Registrar: "Doc-1" }]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.source_signal).toBe("followup_context"); + expect(result.turn_input.semantic_data_need).toBe("document evidence"); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "documents", + asked_action_family: "list_documents", + explicit_entity_candidates: ["СВК-А"], + explicit_date_scope: "2020" + }); + expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_document_evidence_query_documents_v1"); + expect(result.bridge?.answer_draft.answer_mode).toBe("confirmed_with_bounded_inference"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 1307514..68fce10 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -261,6 +261,163 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_followup_context"); }); + it("restarts entity-resolution when the user clarifies one ambiguous candidate", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "СВК-А", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + } + }); + + 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("entity discovery evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["СВК-А"], + unsupported_but_understood_family: "entity_resolution", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_signal_detected"); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_clarification_candidate_selected"); + }); + + it("restarts entity-resolution when the user clarifies an ambiguous candidate by ordinal choice", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "второй вариант", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + } + }); + + 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("entity discovery evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["СВК-Б"], + unsupported_but_understood_family: "entity_resolution", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_clarification_candidate_selected"); + }); + + it("chains an ordinal ambiguity clarification directly into value-flow follow-up intent", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "второй вариант, сколько получили за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + } + }); + + 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("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_entity_resolution_clarification_candidate_selected"); + expect(result.reason_codes).toContain("mcp_discovery_value_flow_signal_detected"); + }); + + it("chains an ordinal ambiguity clarification directly into document evidence follow-up intent", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "второй вариант, покажи документы за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + } + }); + + 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("document evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "documents", + asked_action_family: "list_documents", + explicit_entity_candidates: ["СВК-Б"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "document_evidence", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_clarification_candidate_selected"); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_clarified_document_followup"); + }); + + it("chains an ordinal ambiguity clarification directly into movement evidence follow-up intent", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "первый вариант, покажи движения за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + } + }); + + 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("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["СВК-А"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_clarification_candidate_selected"); + expect(result.reason_codes).toContain("mcp_discovery_entity_resolution_clarified_movement_followup"); + }); + + it("does not silently ground value-flow follow-ups from an ambiguous entity-resolution answer", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "сколько получили по нему за 2020 год", + followupContext: { + previous_discovery_pilot_scope: "entity_resolution_search_v1", + previous_discovery_entity_resolution_status: "ambiguous", + previous_discovery_entity_candidates: ["СВК", "СВК-А", "СВК-Б"], + previous_discovery_entity_ambiguity_candidates: ["СВК-А", "СВК-Б"] + } + }); + + 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_date_scope: "2020", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).not.toContain("mcp_discovery_grounded_value_flow_followup"); + }); + it("overrides a wrong exact document intent when a grounded document follow-up asks to switch into movements", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "а теперь по нему движения за 2020 год", diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 0990df9..835b3fc 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1173,6 +1173,65 @@ describe("assistantTransitionPolicy", () => { }); }); + it("carries ambiguity candidates from entity-resolution discovery into followup context", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => null, + hasAddressFollowupContextSignal: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "СВК-А", + [ + { + role: "assistant", + text: "По каталогу 1С нашлось несколько похожих контрагентов: СВК-А, СВК-Б.", + 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: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["СВК"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: false, + pilot: { + pilot_scope: "entity_resolution_search_v1", + derived_entity_resolution: { + requested_entity: "СВК", + resolution_status: "ambiguous", + resolved_entity: null, + ambiguity_candidates: ["СВК-А", "СВК-Б"] + } + }, + answer_draft: { + answer_mode: "needs_clarification" + } + } + } + } + } + ], + null, + null, + null + ); + + expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe("entity_resolution_search_v1"); + expect(carryover?.followupContext?.previous_discovery_entity_resolution_status).toBe("ambiguous"); + expect(carryover?.followupContext?.previous_discovery_entity_candidates).toEqual(["СВК", "СВК-А", "СВК-Б"]); + expect(carryover?.followupContext?.previous_discovery_entity_ambiguity_candidates).toEqual([ + "СВК-А", + "СВК-Б" + ]); + }); + it("keeps exact payout carryover for a short net follow-up without restating counterparty or year", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({