From 70325dacb69ba10709c71e3949e1618131580064 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 22 Apr 2026 22:30:33 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D1=83=D0=B4=D0=B5=D1=80=D0=B6=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20open-scope=20ranking=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20org=20clarification=20=D0=B8=20year-switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...se38_open_scope_net_org_clarification.json | 79 ++++++++++++ ..._open_scope_ranking_org_clarification.json | 77 ++++++++++++ .../services/assistantContinuityPolicy.js | 9 ++ .../assistantMcpDiscoveryDataNeedGraph.js | 6 +- .../services/assistantMcpDiscoveryPolicy.js | 4 + .../assistantMcpDiscoveryResponsePolicy.js | 21 ++++ .../assistantMcpDiscoveryTurnInputAdapter.js | 18 +-- .../services/assistantTransitionPolicy.js | 2 + .../src/services/assistantContinuityPolicy.ts | 15 +++ .../assistantMcpDiscoveryDataNeedGraph.ts | 6 +- .../services/assistantMcpDiscoveryPolicy.ts | 5 + .../assistantMcpDiscoveryResponsePolicy.ts | 29 +++++ .../assistantMcpDiscoveryTurnInputAdapter.ts | 23 ++-- .../src/services/assistantTransitionPolicy.ts | 6 + ...assistantMcpDiscoveryDataNeedGraph.test.ts | 25 +++- ...ssistantMcpDiscoveryResponsePolicy.test.ts | 115 ++++++++++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 69 ++++++++++- .../tests/assistantTransitionPolicy.test.ts | 58 +++++++++ 18 files changed, 538 insertions(+), 29 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase38_open_scope_net_org_clarification.json create mode 100644 docs/orchestration/address_truth_harness_phase39_open_scope_ranking_org_clarification.json diff --git a/docs/orchestration/address_truth_harness_phase38_open_scope_net_org_clarification.json b/docs/orchestration/address_truth_harness_phase38_open_scope_net_org_clarification.json new file mode 100644 index 0000000..d2c28f5 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase38_open_scope_net_org_clarification.json @@ -0,0 +1,79 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase38_open_scope_net_org_clarification", + "domain": "address_phase38_open_scope_net_org_clarification", + "title": "Phase 38 open-scope net organization clarification", + "description": "Targeted AGENT replay for Big Block D where an open-scope net money-flow question must ask for organization, then resume the same bidirectional contour after an organization-only clarification and preserve it across a short year switch.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_open_scope_net_requires_organization", + "title": "Generic net value-flow question asks for organization first", + "question": "Какое нетто по деньгам за 2020 год: сколько получили и сколько заплатили?", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)уточн|нужно", + "(?i)организац" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_net", "open_scope", "organization_clarification", "bounded_autonomy"] + }, + { + "step_id": "step_02_org_clarification_resumes_net", + "title": "Organization-only clarification resumes the same net contour for 2020", + "question": "по ООО Альтернатива Плюс", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2020", + "(?i)получ|входящ|поступ", + "(?i)заплат|исходящ|списан|платеж", + "(?i)нетто|сальдо|разниц", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)альтернатива", + "(?i)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_net", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_year_switch_reuses_org_and_net", + "title": "Short year-switch follow-up keeps the same organization and bidirectional net contour", + "question": "а за 2021?", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2021", + "(?i)получ|входящ|поступ", + "(?i)заплат|исходящ|списан|платеж", + "(?i)нетто|сальдо|разниц", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)альтернатива", + "(?i)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента", + "(?i)уточните организацию" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_net", "year_switch", "organization_followup_reuse", "bounded_autonomy"] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase39_open_scope_ranking_org_clarification.json b/docs/orchestration/address_truth_harness_phase39_open_scope_ranking_org_clarification.json new file mode 100644 index 0000000..403ea13 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase39_open_scope_ranking_org_clarification.json @@ -0,0 +1,77 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase39_open_scope_ranking_org_clarification", + "domain": "address_phase39_open_scope_ranking_org_clarification", + "title": "Phase 39 open-scope ranking organization clarification", + "description": "Targeted AGENT replay for Big Block D where an open-scope top-value-flow question must ask for organization, then resume the same ranking contour after an organization-only clarification and preserve it across a short year switch.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_open_scope_ranking_requires_organization", + "title": "Generic top customer question asks for organization before ranking", + "question": "Кто больше всего принес денег за 2020 год?", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)уточн|нужно", + "(?i)организац" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_ranking", "open_scope", "organization_clarification", "bounded_autonomy"] + }, + { + "step_id": "step_02_org_clarification_resumes_ranking", + "title": "Organization-only clarification resumes the same ranking contour for 2020", + "question": "по ООО Альтернатива Плюс", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2020", + "(?i)клиент|контрагент|заказчик", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)больше всего|топ|самый доходный|наибол", + "(?i)альтернатива", + "(?i)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_ranking", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_year_switch_reuses_org_and_ranking", + "title": "Short year-switch follow-up keeps the same organization and ranking contour", + "question": "а за 2021?", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2021", + "(?i)клиент|контрагент|заказчик", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)больше всего|топ|самый доходный|наибол", + "(?i)альтернатива", + "(?i)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента", + "(?i)уточните организацию" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_ranking", "year_switch", "organization_followup_reuse", "bounded_autonomy"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index bcfd044..1337180 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -4,6 +4,7 @@ exports.readAssistantMcpDiscoveryEntityResolutionStatus = readAssistantMcpDiscov exports.readAssistantMcpDiscoveryEntityAmbiguityCandidates = readAssistantMcpDiscoveryEntityAmbiguityCandidates; exports.readAssistantMcpDiscoveryEntityCandidates = readAssistantMcpDiscoveryEntityCandidates; exports.readAssistantMcpDiscoveryPilotScope = readAssistantMcpDiscoveryPilotScope; +exports.readAssistantMcpDiscoveryRankingNeed = readAssistantMcpDiscoveryRankingNeed; exports.readAssistantMcpDiscoveryMetadataRouteFamily = readAssistantMcpDiscoveryMetadataRouteFamily; exports.readAssistantMcpDiscoveryMetadataSelectedEntitySet = readAssistantMcpDiscoveryMetadataSelectedEntitySet; exports.readAssistantMcpDiscoveryMetadataAmbiguityDetected = readAssistantMcpDiscoveryMetadataAmbiguityDetected; @@ -76,6 +77,11 @@ function readAssistantMcpDiscoveryTurnMeaning(debug) { const turnInput = toRecordObject(entry?.turn_input); return toRecordObject(turnInput?.turn_meaning_ref); } +function readAssistantMcpDiscoveryDataNeedGraph(debug) { + const entry = readAssistantMcpDiscoveryEntry(debug); + const turnInput = toRecordObject(entry?.turn_input); + return toRecordObject(turnInput?.data_need_graph); +} function readAssistantMcpDiscoveryTurnMeaningMetadataAmbiguityEntitySets(debug, toNonEmptyString = fallbackToNonEmptyString) { const values = readAssistantMcpDiscoveryTurnMeaning(debug)?.metadata_ambiguity_entity_sets; if (!Array.isArray(values)) { @@ -142,6 +148,9 @@ function readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString = fallbackT const pilot = toRecordObject(bridge?.pilot); return toNonEmptyString(pilot?.pilot_scope); } +function readAssistantMcpDiscoveryRankingNeed(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.ranking_need); +} function readAssistantMcpDiscoveryMetadataRouteFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { return toNonEmptyString(readAssistantMcpDiscoveryDerivedMetadataSurface(debug)?.downstream_route_family); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index 7eb945b..e4257c4 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -239,6 +239,7 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const unsupported = lower(turnMeaning?.unsupported_but_understood_family); const rawUtterance = lower(input.rawUtterance); const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis); + const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need); const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope); const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) @@ -252,7 +253,7 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { }); const aggregationNeed = aggregationNeedFor(aggregationAxis); const comparisonNeed = comparisonNeedFor(action); - const rankingNeed = rankingNeedFromRawUtterance(rawUtterance); + const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action); const openScopeWithoutSubject = subjectCandidates.length === 0 && allowsOpenScopeWithoutSubject({ @@ -270,9 +271,6 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { if (subjectCandidates.length === 0 && businessFactFamily === "value_flow" && openScopeWithoutSubject && - !rankingNeed && - !comparisonNeed && - oneSidedOpenScopeTotalHint && !explicitOrganizationScope) { pushUnique(clarificationGaps, "organization"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js index af56528..4577efc 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -75,6 +75,7 @@ function normalizeTurnMeaning(value) { const domain = toNonEmptyString(value.asked_domain_family); const action = toNonEmptyString(value.asked_action_family); const aggregationAxis = toNonEmptyString(value.asked_aggregation_axis); + const seededRankingNeed = toNonEmptyString(value.seeded_ranking_need); const organization = toNonEmptyString(value.explicit_organization_scope); const dateScope = toNonEmptyString(value.explicit_date_scope); const unsupported = toNonEmptyString(value.unsupported_but_understood_family); @@ -89,6 +90,9 @@ function normalizeTurnMeaning(value) { if (aggregationAxis) { result.asked_aggregation_axis = aggregationAxis; } + if (seededRankingNeed) { + result.seeded_ranking_need = seededRankingNeed; + } if (entities.length > 0) { result.explicit_entity_candidates = entities; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index ba04222..1f73344 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -151,6 +151,21 @@ function isOpenScopeValueFlowWithoutSubject(entryPoint) { subjectCandidates.length === 0 && reasonCodes.some((reason) => toNonEmptyString(reason) === "data_need_graph_open_scope_total_without_subject")); } +function needsOpenScopeValueFlowOrganizationClarification(entryPoint) { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const businessFactFamily = toNonEmptyString(graph?.business_fact_family); + const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : []; + const clarificationGaps = Array.isArray(graph?.clarification_gaps) ? graph.clarification_gaps : []; + return (businessFactFamily === "value_flow" && + subjectCandidates.length === 0 && + clarificationGaps.some((gap) => toNonEmptyString(gap) === "organization")); +} +function isOpenScopeValueFlowRanking(entryPoint) { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const businessFactFamily = toNonEmptyString(graph?.business_fact_family); + const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : []; + return businessFactFamily === "value_flow" && subjectCandidates.length === 0 && Boolean(toNonEmptyString(graph?.ranking_need)); +} function readTruthAnswerShape(input) { const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); if (directShape) { @@ -234,6 +249,12 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) { if (!detectedIntent || (!askedDomain && !askedAction && !unsupportedFamily)) { return false; } + if (isOpenScopeValueFlowRanking(entryPoint)) { + return true; + } + if (needsOpenScopeValueFlowOrganizationClarification(entryPoint)) { + return true; + } if (detectedIntent === "customer_revenue_and_payments" && isOpenScopeValueFlowWithoutSubject(entryPoint)) { return true; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 1e892b5..f37eb9f 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -270,6 +270,7 @@ function collectFollowupDiscoverySeed(followupContext) { discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, entityResolutionStatus, entityResolutionAmbiguityCandidates, + rankingNeed: toNonEmptyString(followupContext?.previous_discovery_ranking_need), organization, dateScope, metadataRouteFamily: toNonEmptyString(followupContext?.previous_discovery_metadata_route_family), @@ -603,12 +604,11 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope); const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText); const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText); - const explicitOrganizationScopeSignal = Boolean(rawOrganizationMentionSignal && - (rawOrganizationScope ?? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope)); + const currentTurnOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope; + const explicitOrganizationScopeSignal = Boolean(rawOrganizationMentionSignal && currentTurnOrganizationScope); const organizationClarificationFollowupApplicable = Boolean(followupSeed.domain === "counterparty_value" && !followupSeed.counterparty && - rawOrganizationMentionSignal && - (rawOrganizationScope || followupSeed.organization) && + currentTurnOrganizationScope && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal); @@ -873,16 +873,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) { hasValueRankingSignal(rawText) || rawOpenScopeValueFlowOrganizationSignal || explicitOrganizationScopeSignal || + organizationClarificationFollowupApplicable || followupSeed.organization); if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) { pushUnique(entityCandidates, predecomposeEntities.organization); pushUnique(entityCandidates, followupSeed.organization); } const explicitOrganizationScope = valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty - ? rawOrganizationScope ?? - predecomposeEntities.organization ?? - assistantTurnMeaningOrganizationScope ?? - followupSeed.organization + ? currentTurnOrganizationScope ?? followupSeed.organization : null; if (valueFlowOrganizationStaysScope && explicitOrganizationScope) { for (let index = entityCandidates.length - 1; index >= 0; index -= 1) { @@ -924,6 +922,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? metadataActionFromRawText(rawText) ?? seededAction : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, + seeded_ranking_need: valueFlowSignal && followupSeed.rankingNeed ? followupSeed.rankingNeed : undefined, explicit_entity_candidates: entityCandidates, metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 ? followupSeed.metadataAmbiguityEntitySets @@ -974,6 +973,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (toNonEmptyString(turnMeaning.asked_aggregation_axis)) { cleanTurnMeaning.asked_aggregation_axis = turnMeaning.asked_aggregation_axis; } + if (toNonEmptyString(turnMeaning.seeded_ranking_need)) { + cleanTurnMeaning.seeded_ranking_need = turnMeaning.seeded_ranking_need; + } if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index ccce7d8..631cae6 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -505,6 +505,7 @@ function createAssistantTransitionPolicy(deps) { 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 sourceDiscoveryRankingNeed = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryRankingNeed)(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; @@ -742,6 +743,7 @@ function createAssistantTransitionPolicy(deps) { previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, previous_discovery_entity_resolution_status: sourceDiscoveryEntityResolutionStatus ?? undefined, previous_discovery_entity_candidates: sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, + previous_discovery_ranking_need: sourceDiscoveryRankingNeed ?? undefined, previous_discovery_entity_ambiguity_candidates: sourceDiscoveryEntityAmbiguityCandidates.length > 0 ? sourceDiscoveryEntityAmbiguityCandidates : undefined, diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index cc72090..0158a07 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -142,6 +142,14 @@ function readAssistantMcpDiscoveryTurnMeaning( return toRecordObject(turnInput?.turn_meaning_ref); } +function readAssistantMcpDiscoveryDataNeedGraph( + debug: Record | null +): Record | null { + const entry = readAssistantMcpDiscoveryEntry(debug); + const turnInput = toRecordObject(entry?.turn_input); + return toRecordObject(turnInput?.data_need_graph); +} + function readAssistantMcpDiscoveryTurnMeaningMetadataAmbiguityEntitySets( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString @@ -248,6 +256,13 @@ export function readAssistantMcpDiscoveryPilotScope( return toNonEmptyString(pilot?.pilot_scope); } +export function readAssistantMcpDiscoveryRankingNeed( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryDataNeedGraph(debug)?.ranking_need); +} + export function readAssistantMcpDiscoveryMetadataRouteFamily( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index c3258e3..0cda799 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -326,6 +326,7 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const unsupported = lower(turnMeaning?.unsupported_but_understood_family); const rawUtterance = lower(input.rawUtterance); const aggregationAxis = lower(turnMeaning?.asked_aggregation_axis); + const seededRankingNeed = toNonEmptyString(turnMeaning?.seeded_ranking_need); const explicitDateScope = toNonEmptyString(turnMeaning?.explicit_date_scope); const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) @@ -339,7 +340,7 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( }); const aggregationNeed = aggregationNeedFor(aggregationAxis); const comparisonNeed = comparisonNeedFor(action); - const rankingNeed = rankingNeedFromRawUtterance(rawUtterance); + const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action); const openScopeWithoutSubject = subjectCandidates.length === 0 && @@ -359,9 +360,6 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( subjectCandidates.length === 0 && businessFactFamily === "value_flow" && openScopeWithoutSubject && - !rankingNeed && - !comparisonNeed && - oneSidedOpenScopeTotalHint && !explicitOrganizationScope ) { pushUnique(clarificationGaps, "organization"); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index effb0a7..940bff7 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -24,6 +24,7 @@ export interface AssistantMcpDiscoveryTurnMeaningRef { asked_domain_family?: string | null; asked_action_family?: string | null; asked_aggregation_axis?: string | null; + seeded_ranking_need?: string | null; explicit_entity_candidates?: string[]; metadata_ambiguity_entity_sets?: string[]; explicit_organization_scope?: string | null; @@ -167,6 +168,7 @@ function normalizeTurnMeaning( const domain = toNonEmptyString(value.asked_domain_family); const action = toNonEmptyString(value.asked_action_family); const aggregationAxis = toNonEmptyString(value.asked_aggregation_axis); + const seededRankingNeed = toNonEmptyString(value.seeded_ranking_need); const organization = toNonEmptyString(value.explicit_organization_scope); const dateScope = toNonEmptyString(value.explicit_date_scope); const unsupported = toNonEmptyString(value.unsupported_but_understood_family); @@ -181,6 +183,9 @@ function normalizeTurnMeaning( if (aggregationAxis) { result.asked_aggregation_axis = aggregationAxis; } + if (seededRankingNeed) { + result.seeded_ranking_need = seededRankingNeed; + } if (entities.length > 0) { result.explicit_entity_candidates = entities; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index c3ff343..d8ff481 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -235,6 +235,29 @@ function isOpenScopeValueFlowWithoutSubject( ); } +function needsOpenScopeValueFlowOrganizationClarification( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const businessFactFamily = toNonEmptyString(graph?.business_fact_family); + const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : []; + const clarificationGaps = Array.isArray(graph?.clarification_gaps) ? graph.clarification_gaps : []; + return ( + businessFactFamily === "value_flow" && + subjectCandidates.length === 0 && + clarificationGaps.some((gap) => toNonEmptyString(gap) === "organization") + ); +} + +function isOpenScopeValueFlowRanking( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const businessFactFamily = toNonEmptyString(graph?.business_fact_family); + const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : []; + return businessFactFamily === "value_flow" && subjectCandidates.length === 0 && Boolean(toNonEmptyString(graph?.ranking_need)); +} + function readTruthAnswerShape(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): Record | null { const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); if (directShape) { @@ -335,6 +358,12 @@ function hasSemanticConflictWithDiscoveryTurnMeaning( if (!detectedIntent || (!askedDomain && !askedAction && !unsupportedFamily)) { return false; } + if (isOpenScopeValueFlowRanking(entryPoint)) { + return true; + } + if (needsOpenScopeValueFlowOrganizationClarification(entryPoint)) { + return true; + } if ( detectedIntent === "customer_revenue_and_payments" && isOpenScopeValueFlowWithoutSubject(entryPoint) diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index dbd2435..69c036b 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -317,6 +317,7 @@ function collectFollowupDiscoverySeed(followupContext: Record | discoveryEntity: string | null; entityResolutionStatus: string | null; entityResolutionAmbiguityCandidates: string[]; + rankingNeed: string | null; organization: string | null; dateScope: string | null; metadataRouteFamily: string | null; @@ -365,6 +366,7 @@ function collectFollowupDiscoverySeed(followupContext: Record | discoveryEntity: ambiguityBlocksImplicitGrounding ? null : discoveryEntities[0] ?? null, entityResolutionStatus, entityResolutionAmbiguityCandidates, + rankingNeed: toNonEmptyString(followupContext?.previous_discovery_ranking_need), organization, dateScope, metadataRouteFamily: toNonEmptyString(followupContext?.previous_discovery_metadata_route_family), @@ -819,15 +821,13 @@ export function buildAssistantMcpDiscoveryTurnInput( const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope); const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText); const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText); - const explicitOrganizationScopeSignal = Boolean( - rawOrganizationMentionSignal && - (rawOrganizationScope ?? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope) - ); + const currentTurnOrganizationScope = + rawOrganizationScope ?? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope; + const explicitOrganizationScopeSignal = Boolean(rawOrganizationMentionSignal && currentTurnOrganizationScope); const organizationClarificationFollowupApplicable = Boolean( followupSeed.domain === "counterparty_value" && !followupSeed.counterparty && - rawOrganizationMentionSignal && - (rawOrganizationScope || followupSeed.organization) && + currentTurnOrganizationScope && !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal @@ -1150,6 +1150,7 @@ export function buildAssistantMcpDiscoveryTurnInput( hasValueRankingSignal(rawText) || rawOpenScopeValueFlowOrganizationSignal || explicitOrganizationScopeSignal || + organizationClarificationFollowupApplicable || followupSeed.organization ); if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) { @@ -1158,10 +1159,7 @@ export function buildAssistantMcpDiscoveryTurnInput( } const explicitOrganizationScope = valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty - ? rawOrganizationScope ?? - predecomposeEntities.organization ?? - assistantTurnMeaningOrganizationScope ?? - followupSeed.organization + ? currentTurnOrganizationScope ?? followupSeed.organization : null; if (valueFlowOrganizationStaysScope && explicitOrganizationScope) { for (let index = entityCandidates.length - 1; index >= 0; index -= 1) { @@ -1205,6 +1203,8 @@ export function buildAssistantMcpDiscoveryTurnInput( ? metadataActionFromRawText(rawText) ?? seededAction : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, + seeded_ranking_need: + valueFlowSignal && followupSeed.rankingNeed ? followupSeed.rankingNeed : undefined, explicit_entity_candidates: entityCandidates, metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 @@ -1260,6 +1260,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (toNonEmptyString(turnMeaning.asked_aggregation_axis)) { cleanTurnMeaning.asked_aggregation_axis = turnMeaning.asked_aggregation_axis; } + if (toNonEmptyString(turnMeaning.seeded_ranking_need)) { + cleanTurnMeaning.seeded_ranking_need = turnMeaning.seeded_ranking_need; + } if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 9f92215..b157852 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -17,6 +17,7 @@ import { readAssistantMcpDiscoveryEntityResolutionStatus, readAssistantMcpDiscoveryMetadataRouteFamily, readAssistantMcpDiscoveryMetadataSelectedEntitySet, + readAssistantMcpDiscoveryRankingNeed, readAddressDebugTemporalScope, readAssistantMcpDiscoveryPilotScope, resolveOrganizationClarificationContinuation, @@ -683,6 +684,10 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryRankingNeed = readAssistantMcpDiscoveryRankingNeed( + carryoverSourceDebug, + deps.toNonEmptyString + ); const sourceDiscoveryEntityAmbiguityCandidates = readAssistantMcpDiscoveryEntityAmbiguityCandidates( carryoverSourceDebug, deps.toNonEmptyString @@ -1026,6 +1031,7 @@ export function createAssistantTransitionPolicy(deps) { previous_discovery_entity_resolution_status: sourceDiscoveryEntityResolutionStatus ?? undefined, previous_discovery_entity_candidates: sourceDiscoveryEntityCandidates.length > 0 ? sourceDiscoveryEntityCandidates : undefined, + previous_discovery_ranking_need: sourceDiscoveryRankingNeed ?? undefined, previous_discovery_entity_ambiguity_candidates: sourceDiscoveryEntityAmbiguityCandidates.length > 0 ? sourceDiscoveryEntityAmbiguityCandidates diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts index 10e44d5..1ed8fd1 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts @@ -84,13 +84,34 @@ describe("assistant MCP discovery data need graph", () => { expect(result.business_fact_family).toBe("value_flow"); expect(result.ranking_need).toBe("top_desc"); - expect(result.clarification_gaps).toEqual([]); + expect(result.clarification_gaps).toEqual(["organization"]); + expect(result.proof_expectation).toBe("clarification_required"); expect(result.decomposition_candidates).toEqual([ "collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage" ]); expect(result.reason_codes).toContain("data_need_graph_ranking_top_desc"); + expect(result.reason_codes).toContain("data_need_graph_open_scope_total_needs_organization"); + }); + + it("keeps organization-scoped ranking executable when the ranking axis comes from follow-up context", () => { + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "counterparty value-flow evidence", + rawUtterance: "по ООО Альтернатива Плюс", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + seeded_ranking_need: "top_desc" + } + }); + + expect(result.business_fact_family).toBe("value_flow"); + expect(result.ranking_need).toBe("top_desc"); + expect(result.clarification_gaps).toEqual([]); + expect(result.proof_expectation).toBe("coverage_checked_fact"); }); it("treats incoming-vs-outgoing comparison as an open-scope value need rather than a missing-subject fact ask", () => { @@ -106,7 +127,7 @@ describe("assistant MCP discovery data need graph", () => { expect(result.business_fact_family).toBe("value_flow"); expect(result.comparison_need).toBe("incoming_vs_outgoing"); - expect(result.clarification_gaps).toEqual([]); + expect(result.clarification_gaps).toEqual(["organization"]); expect(result.decomposition_candidates).toEqual([ "collect_incoming_movements", "collect_outgoing_movements", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index b06cb74..68d0892 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -235,6 +235,121 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_factual_address_continuation_target"); }); + it("overrides an exact ranking-shaped address reply when open-scope ranking still needs organization", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Самый доходный год по подтвержденным поступлениям: 2020 (15 744 052,48 ₽ по 20 операциям).", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + data_need_graph: { + business_fact_family: "value_flow", + subject_candidates: [], + ranking_need: "top_desc", + clarification_gaps: ["organization"], + reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"] + }, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020", + seeded_ranking_need: "top_desc" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "Нужно уточнить организацию.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: ["Без организации поиск по контрагентам не запустить."], + limitation_lines: [], + next_step_line: "Уточните организацию, и я продолжу поиск по контрагентам." + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_aligned_factual_address_reply"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_factual_address_continuation_target"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); + }); + + it("overrides an exact ranking-shaped address reply when bounded open-scope ranking already has organization and period", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Самый доходный клиент за доступное время по подтвержденным поступлениям: Группа СВК (12 224 925,00 ₽ по 16 операциям).", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + dialogContinuationContract: { + target_intent: "customer_revenue_and_payments" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + data_need_graph: { + business_fact_family: "value_flow", + subject_candidates: [], + ranking_need: "top_desc", + clarification_gaps: [], + reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"] + }, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + seeded_ranking_need: "top_desc" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Рейтинг по контрагентам построен по подтвержденным строкам 1С.", + confirmed_lines: [ + "Больше всего денег принёс контрагент СБЕРБАНК, ПАО по организации ООО Альтернатива Плюс за период 2020: 12 792 194,31 руб. по 9 строкам с суммой." + ], + inference_lines: [ + "Рейтинг по контрагентам по организации ООО Альтернатива Плюс за период 2020 рассчитан только по подтвержденным строкам 1С." + ], + unknown_lines: ["Полный исторический рейтинг вне проверенного окна не доказан."], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("ООО Альтернатива Плюс"); + expect(result.reply_text).toContain("2020"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_aligned_factual_address_reply"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_factual_address_continuation_target"); + }); + it("keeps full-confirmed factual address replies even when discovery has a guarded candidate", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "ООО Ромашка | сумма: 128000 | операций: 3", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 7583012..6e4a1d4 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1273,7 +1273,7 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); expect(result.data_need_graph?.business_fact_family).toBe("value_flow"); expect(result.data_need_graph?.ranking_need).toBe("top_desc"); - expect(result.data_need_graph?.clarification_gaps).toEqual([]); + expect(result.data_need_graph?.clarification_gaps).toEqual(["organization"]); expect(result.data_need_graph?.decomposition_candidates).toEqual([ "collect_scoped_movements", "aggregate_ranked_axis_values", @@ -1476,6 +1476,73 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_seeded_from_followup_context"); expect(result.data_need_graph?.clarification_gaps).toEqual([]); }); + + it("resumes an open-scope ranking from follow-up context when the user clarifies only the organization", () => { + const orgName = "РћРћРћ Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "РїРѕ РћРћРћ Альтернатива Плюс", + predecomposeContract: { + entities: { organization: orgName } + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + previous_discovery_ranking_need: "top_desc", + previous_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.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", + seeded_ranking_need: "top_desc", + explicit_organization_scope: orgName, + 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.data_need_graph?.ranking_need).toBe("top_desc"); + expect(result.data_need_graph?.clarification_gaps).toEqual([]); + }); + + it("keeps seeded ranking through a year-switch follow-up after organization clarification", () => { + const orgName = "РћРћРћ Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "Р° Р·Р° 2021?", + followupContext: { + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + previous_discovery_ranking_need: "top_desc", + previous_filters: { + organization: orgName, + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.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", + seeded_ranking_need: "top_desc", + explicit_organization_scope: orgName, + explicit_date_scope: "2021", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }); + expect(result.data_need_graph?.ranking_need).toBe("top_desc"); + expect(result.data_need_graph?.clarification_gaps).toEqual([]); + }); it("forces discovery over a supported exact intent when organization-only follow-up resolves an open-scope total", () => { const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; const result = buildAssistantMcpDiscoveryTurnInput({ diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 835b3fc..c468fec 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1274,6 +1274,64 @@ describe("assistantTransitionPolicy", () => { }); }); + it("carries ranking need from grounded discovery into followup context", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => null, + hasAddressFollowupContextSignal: () => true + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "по ООО Альтернатива Плюс", + [ + { + role: "assistant", + 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: { + data_need_graph: { + business_fact_family: "value_flow", + ranking_need: "top_desc", + subject_candidates: [], + clarification_gaps: ["organization"] + }, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020", + seeded_ranking_need: "top_desc" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: false, + pilot: { + pilot_scope: "counterparty_value_flow_query_movements_v1" + }, + answer_draft: { + answer_mode: "needs_clarification" + } + } + } + } + } + ], + null, + null, + null + ); + + expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe( + "counterparty_value_flow_query_movements_v1" + ); + expect(carryover?.followupContext?.previous_discovery_ranking_need).toBe("top_desc"); + expect(carryover?.followupContext?.target_intent).toBe("customer_revenue_and_payments"); + }); + it("carries grounded metadata downstream route hints into followup context", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => null,