From e18b0922adc99f4cf9ec6a96f020f15d313da560 Mon Sep 17 00:00:00 2001 From: dctouch Date: Thu, 23 Apr 2026 12:20:39 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B7=D0=B0=D0=BC=D0=BA=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20multi-hop=20ranking=20clarification=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._multi_hop_ranking_clarification_loop.json | 70 ++++++++++++++++ .../assistantMcpDiscoveryAnswerAdapter.js | 21 +++-- .../services/assistantMcpDiscoveryPlanner.js | 1 + .../services/assistantMcpDiscoveryPolicy.js | 2 + .../assistantMcpDiscoveryRuntimeBridge.js | 3 +- .../assistantMcpDiscoveryTurnInputAdapter.js | 38 ++++++++- .../assistantMcpDiscoveryAnswerAdapter.ts | 23 +++-- .../services/assistantMcpDiscoveryPlanner.ts | 1 + .../services/assistantMcpDiscoveryPolicy.ts | 4 + .../assistantMcpDiscoveryRuntimeBridge.ts | 3 +- .../assistantMcpDiscoveryTurnInputAdapter.ts | 54 +++++++++++- ...assistantMcpDiscoveryAnswerAdapter.test.ts | 40 +++++++++ ...stantMcpDiscoveryRuntimeEntryPoint.test.ts | 83 +++++++++++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 40 +++++++++ 14 files changed, 363 insertions(+), 20 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase43_multi_hop_ranking_clarification_loop.json diff --git a/docs/orchestration/address_truth_harness_phase43_multi_hop_ranking_clarification_loop.json b/docs/orchestration/address_truth_harness_phase43_multi_hop_ranking_clarification_loop.json new file mode 100644 index 0000000..4dd9aaa --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase43_multi_hop_ranking_clarification_loop.json @@ -0,0 +1,70 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase43_multi_hop_ranking_clarification_loop", + "domain": "address_phase43_multi_hop_ranking_clarification_loop", + "title": "Phase 43 multi-hop ranking clarification loop", + "description": "Targeted AGENT replay for Big Block F where an open-scope ranking question must ask for both organization and period, then keep the same ranking loop after an organization-only clarification, and finally answer after the second clarification provides the period.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_ranking_requires_org_and_period", + "title": "Generic ranking question asks for both organization and period", + "question": "Кто больше всего принес денег?", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)уточн|нужно", + "(?i)организац", + "(?i)период" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_ranking", "multi_hop_clarification", "organization_scope", "period_scope", "bounded_autonomy"] + }, + { + "step_id": "step_02_org_only_clarification_keeps_same_loop", + "title": "Organization-only clarification preserves the same ranking loop and asks only for the period", + "question": "по ООО Альтернатива Плюс", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)уточн|нужно", + "(?i)период" + ], + "forbidden_answer_patterns": [ + "(?i)организац", + "(?i)уточните контрагента", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_ranking", "multi_hop_clarification", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_period_clarification_completes_same_ranking_loop", + "title": "Period clarification completes the same ranking loop and yields a bounded ranking answer", + "question": "за 2020 год", + "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", "multi_hop_clarification", "period_followup_reuse", "bounded_autonomy"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 6ec3f3c..61e54cf 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -193,12 +193,24 @@ function dryRunMissingAxis(pilot, axis) { } return pilot.dry_run.execution_steps.some((step) => step.missing_axis_options.some((option) => option.includes(axis))); } +function queryPlanClarificationGaps(pilot) { + const values = pilot.evidence.query_plan.clarification_gaps; + return Array.isArray(values) ? uniqueStrings(values) : []; +} +function clarificationGapMissing(pilot, axis) { + const gaps = queryPlanClarificationGaps(pilot); + if (gaps.length > 0) { + return gaps.includes(axis); + } + return dryRunMissingAxis(pilot, axis); +} function clarificationNeedRu(pilot) { + const needsPeriod = clarificationGapMissing(pilot, "period"); const organizationScopedOpenTotal = pilot.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") || pilot.dry_run.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") || pilot.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph") || pilot.dry_run.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph"); - if (organizationScopedOpenTotal) { + if (organizationScopedOpenTotal && !needsPeriod) { return { subject: "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e", verb: "\u043d\u0443\u0436\u043d\u043e" @@ -206,8 +218,7 @@ function clarificationNeedRu(pilot) { } const hasCounterparty = dryRunHasAxis(pilot, "counterparty"); const hasAccount = dryRunHasAxis(pilot, "account"); - const needsPeriod = dryRunMissingAxis(pilot, "period"); - const needsOrganization = !hasCounterparty && !hasAccount && dryRunMissingAxis(pilot, "organization"); + const needsOrganization = !hasCounterparty && !hasAccount && clarificationGapMissing(pilot, "organization"); if (needsPeriod && needsOrganization) { return { subject: "проверяемый период и организацию", verb: "нужно" }; } @@ -224,8 +235,8 @@ function clarificationNextStepLine(pilot, laneLabel) { pilot.dry_run.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") || pilot.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph") || pilot.dry_run.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph"); - const needsPeriod = dryRunMissingAxis(pilot, "period"); - const needsOrganization = dryRunMissingAxis(pilot, "organization"); + const needsPeriod = clarificationGapMissing(pilot, "period"); + const needsOrganization = clarificationGapMissing(pilot, "organization"); const scopeSuffix = laneScopeSuffix(pilot); if (organizationScopedOpenTotal && !needsPeriod) { return `Уточните организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index 5069567..16c6960 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -677,6 +677,7 @@ function planAssistantMcpDiscovery(input) { turnMeaning: input.turnMeaning, proposedPrimitives: recipe.primitives, requiredAxes: recipe.axes, + clarificationGaps: dataNeedGraph?.clarification_gaps ?? [], maxProbeCount: budgetOverride.maxProbeCount }); const review = (0, assistantMcpCatalogIndex_1.reviewAssistantMcpDiscoveryPlanAgainstCatalog)(plan); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js index 4577efc..00dd209 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -134,6 +134,7 @@ function buildAssistantMcpDiscoveryPlan(input) { const semanticDataNeed = toNonEmptyString(input.semanticDataNeed); const turnMeaning = normalizeTurnMeaning(input.turnMeaning); const requiredAxes = toStringList(input.requiredAxes); + const clarificationGaps = toStringList(input.clarificationGaps); const proposed = toStringList(input.proposedPrimitives); const reasonCodes = []; const allowedPrimitives = []; @@ -194,6 +195,7 @@ function buildAssistantMcpDiscoveryPlan(input) { allowed_primitives: allowedPrimitives, rejected_primitives: rejectedPrimitives, required_axes: requiredAxes, + clarification_gaps: clarificationGaps, execution_budget: { max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT), max_rows_per_probe: clampInteger(input.maxRowsPerProbe, DEFAULT_DISCOVERY_BUDGET.max_rows_per_probe, 1, MAX_ROWS_PER_PROBE) diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js index 3339b45..27be645 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js @@ -84,6 +84,7 @@ function entityCandidatesFromPlanner(planner) { return uniqueStrings(values); } function buildLoopState(planner, pilot, bridgeStatus) { + const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? []; return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryRuntimeBridge", @@ -94,7 +95,7 @@ function buildLoopState(planner, pilot, bridgeStatus) { asked_action_family: planner.discovery_plan.turn_meaning_ref?.asked_action_family ?? null, unsupported_but_understood_family: planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family ?? null, ranking_need: planner.data_need_graph?.ranking_need ?? planner.discovery_plan.turn_meaning_ref?.seeded_ranking_need ?? null, - pending_axes: flattenAxes(pilot, "missing_axis_options"), + pending_axes: plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"), provided_axes: flattenAxes(pilot, "provided_axes"), explicit_entity_candidates: entityCandidatesFromPlanner(planner), explicit_organization_scope: planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope ?? null, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 4ff5dac..d5f6a78 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -647,6 +647,15 @@ function collectDateScopeFromRawText(text) { } return null; } +function currentIsoDate() { + return new Date().toISOString().slice(0, 10); +} +function hasRelativeCurrentDateHint(text) { + return /(?:\bсегодня\b|\bна\s+сегодня\b|\bсегодняшн(?:ий|его|ем)\b|\btoday\b|\bas\s+of\s+today\b|\bcurrent\s+date\b)/iu.test(text); +} +function isImplicitCurrentDateScope(value) { + return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && value === currentIsoDate()); +} function semanticNeedFor(input) { const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); if (input.metadataSignal || /(?:metadata|schema|catalog|inspect_(?:catalog|documents|registers|fields))/iu.test(combined)) { @@ -717,6 +726,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText); const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText); const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(rawText); + const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText); const rawDateScope = collectDateScopeFromRawText(rawText); const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; @@ -1023,6 +1033,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { explicitOrganizationScopeSignal || organizationClarificationFollowupApplicable || followupSeed.organization); + const openScopeValueFlowWithoutResolvedCounterparty = Boolean(valueFlowSignal && !normalizedPredecomposeCounterparty && !followupSeed.counterparty); if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) { pushUnique(entityCandidates, predecomposeEntities.organization); pushUnique(entityCandidates, followupSeed.organization); @@ -1037,14 +1048,33 @@ function buildAssistantMcpDiscoveryTurnInput(input) { } } } + const clarificationLoopStillNeedsPeriod = Boolean(followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period")); + const currentTurnCarriesExplicitPeriod = Boolean(explicitDateScopeLiteralDetected || + rawDateScope || + relativeCurrentDateHintDetected || + (predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope))); + const suppressImplicitCurrentDateScope = Boolean(!currentTurnCarriesExplicitPeriod && + (clarificationLoopStillNeedsPeriod || + openScopeValueFlowWithoutResolvedCounterparty || + (valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal)))); + const normalizedPredecomposeDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope) ? null : predecomposeDateScope; + const normalizedAssistantTurnMeaningDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope) + ? null + : assistantTurnMeaningDateScope; + const normalizedFollowupDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope) + ? null + : followupSeed.dateScope; const explicitDateScope = rawAllTimeScopeSignal ? null - : assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope; + : normalizedAssistantTurnMeaningDateScope ?? + normalizedPredecomposeDateScope ?? + rawDateScope ?? + normalizedFollowupDateScope; const followupDateScopeApplied = Boolean(!rawAllTimeScopeSignal && - !assistantTurnMeaningDateScope && - !predecomposeDateScope && + !normalizedAssistantTurnMeaningDateScope && + !normalizedPredecomposeDateScope && !rawDateScope && - followupSeed.dateScope); + normalizedFollowupDateScope); const clarificationLoopSeedApplied = Boolean(followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopSelectedChainId); const turnMeaning = { asked_domain_family: lifecycleSignal diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index c0bd5de..60dd250 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -267,15 +267,29 @@ function dryRunMissingAxis(pilot: AssistantMcpDiscoveryPilotExecutionContract, a ); } +function queryPlanClarificationGaps(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] { + const values = pilot.evidence.query_plan.clarification_gaps; + return Array.isArray(values) ? uniqueStrings(values) : []; +} + +function clarificationGapMissing(pilot: AssistantMcpDiscoveryPilotExecutionContract, axis: string): boolean { + const gaps = queryPlanClarificationGaps(pilot); + if (gaps.length > 0) { + return gaps.includes(axis); + } + return dryRunMissingAxis(pilot, axis); +} + function clarificationNeedRu( pilot: AssistantMcpDiscoveryPilotExecutionContract ): { subject: string; verb: string } { + const needsPeriod = clarificationGapMissing(pilot, "period"); const organizationScopedOpenTotal = pilot.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") || pilot.dry_run.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") || pilot.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph") || pilot.dry_run.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph"); - if (organizationScopedOpenTotal) { + if (organizationScopedOpenTotal && !needsPeriod) { return { subject: "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e", verb: "\u043d\u0443\u0436\u043d\u043e" @@ -283,8 +297,7 @@ function clarificationNeedRu( } const hasCounterparty = dryRunHasAxis(pilot, "counterparty"); const hasAccount = dryRunHasAxis(pilot, "account"); - const needsPeriod = dryRunMissingAxis(pilot, "period"); - const needsOrganization = !hasCounterparty && !hasAccount && dryRunMissingAxis(pilot, "organization"); + const needsOrganization = !hasCounterparty && !hasAccount && clarificationGapMissing(pilot, "organization"); if (needsPeriod && needsOrganization) { return { subject: "проверяемый период и организацию", verb: "нужно" }; } @@ -306,8 +319,8 @@ function clarificationNextStepLine( pilot.dry_run.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") || pilot.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph") || pilot.dry_run.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph"); - const needsPeriod = dryRunMissingAxis(pilot, "period"); - const needsOrganization = dryRunMissingAxis(pilot, "organization"); + const needsPeriod = clarificationGapMissing(pilot, "period"); + const needsOrganization = clarificationGapMissing(pilot, "organization"); const scopeSuffix = laneScopeSuffix(pilot); if (organizationScopedOpenTotal && !needsPeriod) { return `Уточните организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index 21524e1..a1d2f39 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -852,6 +852,7 @@ export function planAssistantMcpDiscovery( turnMeaning: input.turnMeaning, proposedPrimitives: recipe.primitives, requiredAxes: recipe.axes, + clarificationGaps: dataNeedGraph?.clarification_gaps ?? [], maxProbeCount: budgetOverride.maxProbeCount }); const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index 940bff7..9e2d7a1 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -48,6 +48,7 @@ export interface AssistantMcpDiscoveryPlanContract { allowed_primitives: AssistantMcpDiscoveryPrimitive[]; rejected_primitives: string[]; required_axes: string[]; + clarification_gaps: string[]; execution_budget: AssistantMcpDiscoveryExecutionBudget; requires_evidence_gate: true; answer_may_use_raw_model_claims: false; @@ -59,6 +60,7 @@ export interface BuildAssistantMcpDiscoveryPlanInput { turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null; proposedPrimitives?: string[] | null; requiredAxes?: string[] | null; + clarificationGaps?: string[] | null; maxProbeCount?: number | null; maxRowsPerProbe?: number | null; } @@ -237,6 +239,7 @@ export function buildAssistantMcpDiscoveryPlan( const semanticDataNeed = toNonEmptyString(input.semanticDataNeed); const turnMeaning = normalizeTurnMeaning(input.turnMeaning); const requiredAxes = toStringList(input.requiredAxes); + const clarificationGaps = toStringList(input.clarificationGaps); const proposed = toStringList(input.proposedPrimitives); const reasonCodes: string[] = []; const allowedPrimitives: AssistantMcpDiscoveryPrimitive[] = []; @@ -297,6 +300,7 @@ export function buildAssistantMcpDiscoveryPlan( allowed_primitives: allowedPrimitives, rejected_primitives: rejectedPrimitives, required_axes: requiredAxes, + clarification_gaps: clarificationGaps, execution_budget: { max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT), max_rows_per_probe: clampInteger( diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts index 24e02d4..23feea5 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts @@ -169,6 +169,7 @@ function buildLoopState( pilot: AssistantMcpDiscoveryPilotExecutionContract, bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus ): AssistantMcpDiscoveryLoopStateContract { + const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? []; return { schema_version: ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryRuntimeBridge", @@ -180,7 +181,7 @@ function buildLoopState( unsupported_but_understood_family: planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family ?? null, ranking_need: planner.data_need_graph?.ranking_need ?? planner.discovery_plan.turn_meaning_ref?.seeded_ranking_need ?? null, - pending_axes: flattenAxes(pilot, "missing_axis_options"), + pending_axes: plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"), provided_axes: flattenAxes(pilot, "provided_axes"), explicit_entity_candidates: entityCandidatesFromPlanner(planner), explicit_organization_scope: planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope ?? null, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index ccef3b6..4ebcfdd 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -881,6 +881,20 @@ function collectDateScopeFromRawText(text: string): string | null { return null; } +function currentIsoDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function hasRelativeCurrentDateHint(text: string): boolean { + return /(?:\bсегодня\b|\bна\s+сегодня\b|\bсегодняшн(?:ий|его|ем)\b|\btoday\b|\bas\s+of\s+today\b|\bcurrent\s+date\b)/iu.test( + text + ); +} + +function isImplicitCurrentDateScope(value: string | null): boolean { + return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && value === currentIsoDate()); +} + function semanticNeedFor(input: { domain: string | null; action: string | null; @@ -979,6 +993,7 @@ export function buildAssistantMcpDiscoveryTurnInput( const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText); const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText); const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(rawText); + const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText); const rawDateScope = collectDateScopeFromRawText(rawText); const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; @@ -1356,6 +1371,9 @@ export function buildAssistantMcpDiscoveryTurnInput( organizationClarificationFollowupApplicable || followupSeed.organization ); + const openScopeValueFlowWithoutResolvedCounterparty = Boolean( + valueFlowSignal && !normalizedPredecomposeCounterparty && !followupSeed.counterparty + ); if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) { pushUnique(entityCandidates, predecomposeEntities.organization); pushUnique(entityCandidates, followupSeed.organization); @@ -1371,16 +1389,44 @@ export function buildAssistantMcpDiscoveryTurnInput( } } } + const clarificationLoopStillNeedsPeriod = Boolean( + followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period") + ); + const currentTurnCarriesExplicitPeriod = Boolean( + explicitDateScopeLiteralDetected || + rawDateScope || + relativeCurrentDateHintDetected || + (predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope)) + ); + const suppressImplicitCurrentDateScope = Boolean( + !currentTurnCarriesExplicitPeriod && + (clarificationLoopStillNeedsPeriod || + openScopeValueFlowWithoutResolvedCounterparty || + (valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))) + ); + const normalizedPredecomposeDateScope = + suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope) ? null : predecomposeDateScope; + const normalizedAssistantTurnMeaningDateScope = + suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope) + ? null + : assistantTurnMeaningDateScope; + const normalizedFollowupDateScope = + suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope) + ? null + : followupSeed.dateScope; const explicitDateScope = rawAllTimeScopeSignal ? null - : assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope; + : normalizedAssistantTurnMeaningDateScope ?? + normalizedPredecomposeDateScope ?? + rawDateScope ?? + normalizedFollowupDateScope; const followupDateScopeApplied = Boolean( !rawAllTimeScopeSignal && - !assistantTurnMeaningDateScope && - !predecomposeDateScope && + !normalizedAssistantTurnMeaningDateScope && + !normalizedPredecomposeDateScope && !rawDateScope && - followupSeed.dateScope + normalizedFollowupDateScope ); const clarificationLoopSeedApplied = Boolean( followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopSelectedChainId diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 0997d97..ae38da2 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -256,6 +256,46 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.next_step_line).not.toContain("Уточните контрагента"); }); + it("asks for both organization and period when an open ranking still misses both axes", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "value_flow", + action_family: "turnover", + aggregation_need: null, + time_scope_need: "period_required", + comparison_need: null, + ranking_need: "top_desc", + proof_expectation: "clarification_required", + clarification_gaps: ["organization", "period"], + decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_ranking_top_desc", + "data_need_graph_open_scope_total_needs_organization", + "data_need_graph_has_clarification_gaps" + ] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + seeded_ranking_need: "top_desc" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([])); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.answer_mode).toBe("needs_clarification"); + expect(draft.headline).toContain("период"); + expect(draft.headline).toContain("организац"); + expect(draft.next_step_line).toContain("период"); + expect(draft.next_step_line).toContain("организац"); + }); + it("asks for organization rather than counterparty on open bidirectional comparison when only the period is known", async () => { const planner = planAssistantMcpDiscovery({ dataNeedGraph: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts index c4a3197..0eb0025 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts @@ -325,6 +325,89 @@ describe("assistant MCP discovery runtime entry point", () => { expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_comparison"); }); + it("keeps the same ranking loop after organization-only clarification when period is still missing", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "по ООО Альтернатива Плюс", + predecomposeContract: { + entities: { organization: "ООО Альтернатива Плюс" } + }, + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "value_flow_ranking", + previous_discovery_loop_pending_axes: ["organization", "period"], + previous_discovery_loop_provided_axes: ["aggregate_axis", "amount", "coverage_target"], + previous_discovery_loop_asked_domain_family: "counterparty_value", + previous_discovery_loop_asked_action_family: "turnover", + previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover", + previous_discovery_ranking_need: "top_desc" + }, + deps: buildDeps([]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: "ООО Альтернатива Плюс", + seeded_ranking_need: "top_desc" + }); + expect(result.turn_input.turn_meaning_ref?.explicit_date_scope).toBeUndefined(); + expect(result.bridge?.bridge_status).toBe("needs_clarification"); + expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_ranking"); + expect(result.bridge?.loop_state).toMatchObject({ + loop_status: "awaiting_clarification", + selected_chain_id: "value_flow_ranking", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: null, + ranking_need: "top_desc" + }); + expect(result.bridge?.loop_state.pending_axes).toContain("period"); + expect(result.bridge?.loop_state.pending_axes).not.toContain("organization"); + expect(result.bridge?.answer_draft.next_step_line).toContain("период"); + expect(result.bridge?.answer_draft.next_step_line).not.toContain("организацию"); + }); + + it("completes the same ranking loop after the second clarification provides the period", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "за 2020 год", + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "value_flow_ranking", + previous_discovery_loop_pending_axes: ["period"], + previous_discovery_loop_provided_axes: ["aggregate_axis", "amount", "coverage_target", "organization"], + previous_discovery_loop_asked_domain_family: "counterparty_value", + previous_discovery_loop_asked_action_family: "turnover", + previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover", + previous_discovery_ranking_need: "top_desc", + previous_filters: { + organization: "ООО Альтернатива Плюс" + } + }, + deps: buildDeps([ + { Period: "2020-01-10T00:00:00", Amount: 1200, Counterparty: "СВК-А" }, + { Period: "2020-03-11T00:00:00", Amount: 800, Counterparty: "СВК-Б" }, + { Period: "2020-05-12T00:00:00", Amount: 900, Counterparty: "СВК-А" } + ]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + seeded_ranking_need: "top_desc" + }); + expect(result.bridge?.bridge_status).toBe("answer_draft_ready"); + expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_ranking"); + expect(result.bridge?.pilot.derived_ranked_value_flow?.ranked_values[0]).toMatchObject({ + axis_value: "СВК-А", + total_amount: 2100 + }); + expect(result.bridge?.loop_state.loop_status).toBe("ready_for_next_hop"); + expect(result.bridge?.loop_state.pending_axes).toEqual([]); + }); + it.skip("keeps mirrored predecompose organization and counterparty out of the subject lane for open comparison (utf8-safe)", async () => { const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index b46ee14..e85072e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1335,6 +1335,7 @@ describe("assistant MCP discovery turn input adapter", () => { "probe_coverage" ]); }); + it("keeps organization as scope for open bidirectional comparison wording instead of inventing a subject candidate", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?", @@ -1638,6 +1639,45 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_resumed_from_saved_loop_state"); }); + it("does not keep an implicit today date while a ranking clarification loop still needs period", () => { + const todayIso = new Date().toISOString().slice(0, 10); + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "по ООО Альтернатива Плюс", + assistantTurnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: todayIso, + explicit_intent_candidate: "customer_revenue_and_payments" + }, + predecomposeContract: { + entities: { organization: orgName } + }, + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "value_flow_ranking", + previous_discovery_loop_pending_axes: ["organization", "period"], + previous_discovery_loop_provided_axes: ["aggregate_axis", "amount", "coverage_target"], + previous_discovery_loop_asked_domain_family: "counterparty_value", + previous_discovery_loop_asked_action_family: "turnover", + previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover", + previous_discovery_ranking_need: "top_desc", + previous_filters: { + period_to: todayIso + } + } + }); + + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + seeded_ranking_need: "top_desc", + explicit_organization_scope: orgName + }); + expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined(); + expect(result.data_need_graph?.clarification_gaps).toEqual(["period"]); + }); + it("resolves metadata lane choice from saved loop state even without a previous pilot scope", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "по движениям",