From 8a1ae696dcd2ddcdfd784ca9862cb476dc336b44 Mon Sep 17 00:00:00 2001 From: dctouch Date: Thu, 23 Apr 2026 13:00:54 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B4=D0=BE=D0=B1=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?all-time=20=D0=B8=20period-first=20clarification=20loops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...n_net_period_first_clarification_loop.json | 70 +++++++++++++++++++ ...pen_total_all_time_clarification_loop.json | 69 ++++++++++++++++++ .../dist/services/assistantMcpCatalogIndex.js | 16 ++++- .../assistantMcpDiscoveryAnswerAdapter.js | 1 - .../assistantMcpDiscoveryDataNeedGraph.js | 8 +-- .../services/assistantMcpDiscoveryPlanner.js | 6 ++ .../assistantMcpDiscoveryResponseCandidate.js | 1 - .../assistantMcpDiscoveryTurnInputAdapter.js | 29 ++++++-- .../src/services/assistantMcpCatalogIndex.ts | 16 ++++- .../assistantMcpDiscoveryDataNeedGraph.ts | 8 +-- .../services/assistantMcpDiscoveryPlanner.ts | 10 +++ .../assistantMcpDiscoveryTurnInputAdapter.ts | 31 ++++++-- .../tests/assistantMcpCatalogIndex.test.ts | 30 ++++++++ ...assistantMcpDiscoveryDataNeedGraph.test.ts | 23 ++++++ .../assistantMcpDiscoveryPlanner.test.ts | 44 ++++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 61 ++++++++++++++++ 16 files changed, 397 insertions(+), 26 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase47_multi_hop_open_net_period_first_clarification_loop.json create mode 100644 docs/orchestration/address_truth_harness_phase48_multi_hop_open_total_all_time_clarification_loop.json diff --git a/docs/orchestration/address_truth_harness_phase47_multi_hop_open_net_period_first_clarification_loop.json b/docs/orchestration/address_truth_harness_phase47_multi_hop_open_net_period_first_clarification_loop.json new file mode 100644 index 0000000..4dd440e --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase47_multi_hop_open_net_period_first_clarification_loop.json @@ -0,0 +1,70 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase47_multi_hop_open_net_period_first_clarification_loop", + "domain": "address_phase47_multi_hop_open_net_period_first_clarification_loop", + "title": "Phase 47 multi-hop open net period-first clarification loop", + "description": "Targeted AGENT replay for Big Block F where an open-scope net question asks for both organization and period, then keeps the same net loop after a period-only clarification, and finally answers after the second clarification provides the organization.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_open_net_requires_org_and_period", + "title": "Open net question asks for both organization and period", + "question": "Какое нетто по деньгам: сколько получили и сколько заплатили?", + "allowed_reply_types": ["clarification_required", "partial_coverage", "factual_with_explanation"], + "required_answer_patterns_all": [ + "(?i)уточн|нужно", + "(?i)организац", + "(?i)период" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["open_scope_net", "multi_hop_clarification", "organization_scope", "period_scope", "bounded_autonomy"] + }, + { + "step_id": "step_02_period_only_clarification_keeps_same_net_loop", + "title": "Period-only clarification preserves the same net loop and asks only for the organization", + "question": "за 2020 год", + "allowed_reply_types": ["clarification_required", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)уточн|нужно", + "(?i)организац" + ], + "forbidden_answer_patterns": [ + "(?i)период", + "(?i)уточните контрагента", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": ["open_scope_net", "multi_hop_clarification", "period_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_org_clarification_completes_same_net_loop", + "title": "Organization clarification completes the same net loop and yields a bounded net answer", + "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": ["open_scope_net", "multi_hop_clarification", "organization_followup_reuse", "bounded_autonomy"] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase48_multi_hop_open_total_all_time_clarification_loop.json b/docs/orchestration/address_truth_harness_phase48_multi_hop_open_total_all_time_clarification_loop.json new file mode 100644 index 0000000..0aab73d --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase48_multi_hop_open_total_all_time_clarification_loop.json @@ -0,0 +1,69 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase48_multi_hop_open_total_all_time_clarification_loop", + "domain": "address_phase48_multi_hop_open_total_all_time_clarification_loop", + "title": "Phase 48 multi-hop open total all-time clarification loop", + "description": "Targeted AGENT replay for Big Block F where an open-scope incoming-total question asks for both organization and period, then keeps the same total loop after an organization-only clarification, and finally accepts 'за все время' as the period clarification that clears the remaining gap and produces a bounded answer.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_open_total_requires_org_and_period", + "title": "Open total question asks for both organization and period", + "question": "Сколько вообще входящих денег было?", + "allowed_reply_types": ["clarification_required", "partial_coverage", "factual_with_explanation"], + "required_answer_patterns_all": [ + "(?i)уточн|нужно", + "(?i)организац", + "(?i)период" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["open_scope_total", "multi_hop_clarification", "organization_scope", "period_scope", "bounded_autonomy"] + }, + { + "step_id": "step_02_org_only_clarification_keeps_same_total_loop", + "title": "Organization-only clarification preserves the same total 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": ["open_scope_total", "multi_hop_clarification", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_all_time_clarification_completes_same_total_loop", + "title": "All-time clarification clears the remaining period gap and yields a bounded total answer", + "question": "за все время", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)входящ|получ", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)все время|доступное время|проверенн", + "(?i)альтернатива", + "(?i)найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните организацию", + "(?i)уточните период", + "(?i)уточните контрагента", + "(?i)не найден контрагент" + ], + "criticality": "critical", + "semantic_tags": ["open_scope_total", "all_time_scope", "multi_hop_clarification", "bounded_autonomy"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js index 573e69c..cf86b39 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js +++ b/llm_normalizer/backend/dist/services/assistantMcpCatalogIndex.js @@ -65,7 +65,13 @@ const PRIMITIVE_CONTRACTS = [ supported_fact_families: ["value_flow", "movement_evidence"], supported_action_families: ["turnover", "payout", "net_value_flow", "list_movements"], planning_tags: ["movement", "comparison", "ranking", "aggregation", "monthly_aggregation"], - required_axes_any_of: [["period", "account"], ["period", "counterparty"], ["period", "organization"]], + required_axes_any_of: [ + ["period", "account"], + ["period", "counterparty"], + ["period", "organization"], + ["all_time_scope", "counterparty"], + ["all_time_scope", "organization"] + ], optional_axes: ["contract", "document", "amount", "item", "warehouse"], output_fact_kinds: ["movement_rows", "turnover", "balance_delta"], evidence_floor: "rows_matched", @@ -93,7 +99,13 @@ const PRIMITIVE_CONTRACTS = [ supported_fact_families: ["value_flow"], supported_action_families: ["turnover", "payout", "net_value_flow"], planning_tags: ["aggregation", "ranking", "monthly_aggregation"], - required_axes_any_of: [["aggregate_axis", "period"], ["aggregate_axis", "counterparty"], ["aggregate_axis", "account"]], + required_axes_any_of: [ + ["aggregate_axis", "period"], + ["aggregate_axis", "counterparty"], + ["aggregate_axis", "account"], + ["aggregate_axis", "counterparty", "all_time_scope"], + ["aggregate_axis", "organization", "all_time_scope"] + ], optional_axes: ["organization", "contract", "document", "amount"], output_fact_kinds: ["aggregate_totals", "ranked_axis_values"], evidence_floor: "rows_matched", diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index c3a9676..6df44a0 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -46,7 +46,6 @@ function isInternalMechanicsLine(value) { text.includes("runtime_") || text.includes("planner_") || text.includes("catalog_") || - text.includes("mcp discovery") || text.includes("needs more scope before execution") || text.includes("mcp_execution_performed")); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index 47419cf..24fc662 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -100,10 +100,10 @@ function hasOpenScopeOneSidedValueTotalHint(rawUtterance, action) { return false; } if (action === "turnover") { - return /(?:\bсколько\s+(?:мы\s+)?(?:получили|получено|входящих(?:\s+денег)?|поступлений|денег\s+пришло)\b|(?:сумма|объем)\s+(?:входящих|поступлений)|поступлений\s+за\b)/iu.test(rawUtterance); + return /(?:\bсколько\s+(?:(?:вообще|всего|реально)\s+){0,2}(?:мы\s+)?(?:получили|получено|входящих(?:\s+денег)?(?:\s+было)?|поступлений|денег\s+пришло)\b|(?:сумма|объем)\s+(?:входящих|поступлений)|поступлений\s+за\b)/iu.test(rawUtterance); } if (action === "payout") { - return /(?:\bсколько\s+(?:мы\s+)?(?:заплатили|выплатили|потратили|исходящих(?:\s+денег)?|платежей|списаний)\b|(?:сумма|объем)\s+(?:исходящих|платежей|списаний)|(?:платежей|списаний)\s+за\b)/iu.test(rawUtterance); + return /(?:\bсколько\s+(?:(?:вообще|всего|реально)\s+){0,2}(?:мы\s+)?(?:заплатили|выплатили|потратили|исходящих(?:\s+денег)?(?:\s+было)?|платежей(?:\s+было)?|списаний(?:\s+было)?)\b|(?:сумма|объем)\s+(?:исходящих|платежей|списаний)|(?:платежей|списаний)\s+за\b)/iu.test(rawUtterance); } return false; } @@ -112,10 +112,10 @@ function hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action) { return false; } if (action === "turnover") { - return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u043c\u044b\s+)?(?:\u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438|\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e|\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439|\u0434\u0435\u043d\u0435\u0433\s+\u043f\u0440\u0438\u0448\u043b\u043e)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439\s+\u0437\u0430)/u.test(rawUtterance); + return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:(?:\u0432\u043e\u043e\u0431\u0449\u0435|\u0432\u0441\u0435\u0433\u043e|\u0440\u0435\u0430\u043b\u044c\u043d\u043e)\s+){0,2}(?:\u043c\u044b\s+)?(?:\u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438|\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e|\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?(?:\s+\u0431\u044b\u043b\u043e)?|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439|\u0434\u0435\u043d\u0435\u0433\s+\u043f\u0440\u0438\u0448\u043b\u043e)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439\s+\u0437\u0430)/u.test(rawUtterance); } if (action === "payout") { - return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u043c\u044b\s+)?(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u0432\u044b\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043f\u043e\u0442\u0440\u0430\u0442\u0438\u043b\u0438|\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)\s+\u0437\u0430)/u.test(rawUtterance); + return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:(?:\u0432\u043e\u043e\u0431\u0449\u0435|\u0432\u0441\u0435\u0433\u043e|\u0440\u0435\u0430\u043b\u044c\u043d\u043e)\s+){0,2}(?:\u043c\u044b\s+)?(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u0432\u044b\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043f\u043e\u0442\u0440\u0430\u0442\u0438\u043b\u0438|\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?(?:\s+\u0431\u044b\u043b\u043e)?|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439(?:\s+\u0431\u044b\u043b\u043e)?|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439(?:\s+\u0431\u044b\u043b\u043e)?)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)\s+\u0437\u0430)/u.test(rawUtterance); } return false; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index 16c6960..d08c271 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -58,6 +58,11 @@ function addScopeAxes(axes, meaning) { pushUnique(axes, "period"); } } +function addTimeScopeAxes(axes, dataNeedGraph) { + if (dataNeedGraph?.time_scope_need === "all_time_scope") { + pushUnique(axes, "all_time_scope"); + } +} function includesAny(text, tokens) { return tokens.some((token) => text.includes(token)); } @@ -276,6 +281,7 @@ function recipeFor(input) { const axes = []; const requestedAggregationAxis = aggregationAxis(meaning); addScopeAxes(axes, meaning); + addTimeScopeAxes(axes, dataNeedGraph); if (graphClarificationGaps.includes("lane_family_choice")) { pushUnique(axes, "lane_family_choice"); return { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 8d6892f..98116a0 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -56,7 +56,6 @@ function hasInternalMechanics(value) { text.includes("planner_") || text.includes("catalog_") || text.includes("select ") || - text.includes("mcp discovery") || text.includes("needs more scope before execution") || text.includes("mcp_execution_performed")); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index d5f6a78..60fad67 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -768,14 +768,27 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? null : predecomposeEntities.counterparty; const predecomposeDateScope = collectDateScope(predecomposeContract); + const periodClarificationFollowupApplicable = Boolean(followupSeed.domain && + followupSeed.loopStatus === "awaiting_clarification" && + followupSeed.loopPendingAxes.includes("period") && + !rawLifecycleSignal && + !rawMetadataSignal && + (rawAllTimeScopeSignal || + explicitDateScopeLiteralDetected || + rawDateScope || + relativeCurrentDateHintDetected || + (predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope)))); const followupDiscoverySeedApplicable = Boolean(followupSeed.domain && !rawLifecycleSignal && - !rawValueFlowSignal && - (monthlyAggregationSignal || - explicitDateScopeLiteralDetected || - predecomposeDateScope || - explicitOrganizationScopeSignal || - organizationClarificationFollowupApplicable)); + !rawMetadataSignal && + (periodClarificationFollowupApplicable || + (!rawValueFlowSignal && + (monthlyAggregationSignal || + rawAllTimeScopeSignal || + explicitDateScopeLiteralDetected || + predecomposeDateScope || + explicitOrganizationScopeSignal || + organizationClarificationFollowupApplicable)))); const metadataFollowupSeedApplicable = Boolean(followupSeed.domain === "metadata" && !rawLifecycleSignal && !rawValueFlowSignal && @@ -1197,6 +1210,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { groundedValueFlowFollowupApplicable, forceDiscoveryOverExplicitIntent: Boolean(entityResolutionClarificationCandidate) || organizationClarificationFollowupApplicable || + periodClarificationFollowupApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || @@ -1249,6 +1263,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (organizationClarificationFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_organization_clarification_followup_from_followup_context"); } + if (periodClarificationFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_period_clarification_followup_from_followup_context"); + } if (payoutSignal) { pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts index 93a2822..7e89cfa 100644 --- a/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts +++ b/llm_normalizer/backend/src/services/assistantMcpCatalogIndex.ts @@ -98,7 +98,13 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ supported_fact_families: ["value_flow", "movement_evidence"], supported_action_families: ["turnover", "payout", "net_value_flow", "list_movements"], planning_tags: ["movement", "comparison", "ranking", "aggregation", "monthly_aggregation"], - required_axes_any_of: [["period", "account"], ["period", "counterparty"], ["period", "organization"]], + required_axes_any_of: [ + ["period", "account"], + ["period", "counterparty"], + ["period", "organization"], + ["all_time_scope", "counterparty"], + ["all_time_scope", "organization"] + ], optional_axes: ["contract", "document", "amount", "item", "warehouse"], output_fact_kinds: ["movement_rows", "turnover", "balance_delta"], evidence_floor: "rows_matched", @@ -126,7 +132,13 @@ const PRIMITIVE_CONTRACTS: AssistantMcpCatalogPrimitiveContract[] = [ supported_fact_families: ["value_flow"], supported_action_families: ["turnover", "payout", "net_value_flow"], planning_tags: ["aggregation", "ranking", "monthly_aggregation"], - required_axes_any_of: [["aggregate_axis", "period"], ["aggregate_axis", "counterparty"], ["aggregate_axis", "account"]], + required_axes_any_of: [ + ["aggregate_axis", "period"], + ["aggregate_axis", "counterparty"], + ["aggregate_axis", "account"], + ["aggregate_axis", "counterparty", "all_time_scope"], + ["aggregate_axis", "organization", "all_time_scope"] + ], optional_axes: ["organization", "contract", "document", "amount"], output_fact_kinds: ["aggregate_totals", "ranked_axis_values"], evidence_floor: "rows_matched", diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index afa72fa..1c9d629 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -153,12 +153,12 @@ function hasOpenScopeOneSidedValueTotalHint(rawUtterance: string, action: string return false; } if (action === "turnover") { - return /(?:\bсколько\s+(?:мы\s+)?(?:получили|получено|входящих(?:\s+денег)?|поступлений|денег\s+пришло)\b|(?:сумма|объем)\s+(?:входящих|поступлений)|поступлений\s+за\b)/iu.test( + return /(?:\bсколько\s+(?:(?:вообще|всего|реально)\s+){0,2}(?:мы\s+)?(?:получили|получено|входящих(?:\s+денег)?(?:\s+было)?|поступлений|денег\s+пришло)\b|(?:сумма|объем)\s+(?:входящих|поступлений)|поступлений\s+за\b)/iu.test( rawUtterance ); } if (action === "payout") { - return /(?:\bсколько\s+(?:мы\s+)?(?:заплатили|выплатили|потратили|исходящих(?:\s+денег)?|платежей|списаний)\b|(?:сумма|объем)\s+(?:исходящих|платежей|списаний)|(?:платежей|списаний)\s+за\b)/iu.test( + return /(?:\bсколько\s+(?:(?:вообще|всего|реально)\s+){0,2}(?:мы\s+)?(?:заплатили|выплатили|потратили|исходящих(?:\s+денег)?(?:\s+было)?|платежей(?:\s+было)?|списаний(?:\s+было)?)\b|(?:сумма|объем)\s+(?:исходящих|платежей|списаний)|(?:платежей|списаний)\s+за\b)/iu.test( rawUtterance ); } @@ -170,12 +170,12 @@ function hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance: string, action return false; } if (action === "turnover") { - return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u043c\u044b\s+)?(?:\u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438|\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e|\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439|\u0434\u0435\u043d\u0435\u0433\s+\u043f\u0440\u0438\u0448\u043b\u043e)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439\s+\u0437\u0430)/u.test( + return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:(?:\u0432\u043e\u043e\u0431\u0449\u0435|\u0432\u0441\u0435\u0433\u043e|\u0440\u0435\u0430\u043b\u044c\u043d\u043e)\s+){0,2}(?:\u043c\u044b\s+)?(?:\u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438|\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e|\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?(?:\s+\u0431\u044b\u043b\u043e)?|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439|\u0434\u0435\u043d\u0435\u0433\s+\u043f\u0440\u0438\u0448\u043b\u043e)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439\s+\u0437\u0430)/u.test( rawUtterance ); } if (action === "payout") { - return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u043c\u044b\s+)?(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u0432\u044b\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043f\u043e\u0442\u0440\u0430\u0442\u0438\u043b\u0438|\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)\s+\u0437\u0430)/u.test( + return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:(?:\u0432\u043e\u043e\u0431\u0449\u0435|\u0432\u0441\u0435\u0433\u043e|\u0440\u0435\u0430\u043b\u044c\u043d\u043e)\s+){0,2}(?:\u043c\u044b\s+)?(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u0432\u044b\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043f\u043e\u0442\u0440\u0430\u0442\u0438\u043b\u0438|\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?(?:\s+\u0431\u044b\u043b\u043e)?|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439(?:\s+\u0431\u044b\u043b\u043e)?|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439(?:\s+\u0431\u044b\u043b\u043e)?)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)\s+\u0437\u0430)/u.test( rawUtterance ); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index a1d2f39..2e9f185 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -150,6 +150,15 @@ function addScopeAxes(axes: string[], meaning: AssistantMcpDiscoveryTurnMeaningR } } +function addTimeScopeAxes( + axes: string[], + dataNeedGraph: AssistantMcpDiscoveryDataNeedGraphContract | null | undefined +): void { + if (dataNeedGraph?.time_scope_need === "all_time_scope") { + pushUnique(axes, "all_time_scope"); + } +} + function includesAny(text: string, tokens: string[]): boolean { return tokens.some((token) => text.includes(token)); } @@ -417,6 +426,7 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const axes: string[] = []; const requestedAggregationAxis = aggregationAxis(meaning); addScopeAxes(axes, meaning); + addTimeScopeAxes(axes, dataNeedGraph); if (graphClarificationGaps.includes("lane_family_choice")) { pushUnique(axes, "lane_family_choice"); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 4ebcfdd..2ffb3f8 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -1053,15 +1053,30 @@ export function buildAssistantMcpDiscoveryTurnInput( ? null : predecomposeEntities.counterparty; const predecomposeDateScope = collectDateScope(predecomposeContract); + const periodClarificationFollowupApplicable = Boolean( + followupSeed.domain && + followupSeed.loopStatus === "awaiting_clarification" && + followupSeed.loopPendingAxes.includes("period") && + !rawLifecycleSignal && + !rawMetadataSignal && + (rawAllTimeScopeSignal || + explicitDateScopeLiteralDetected || + rawDateScope || + relativeCurrentDateHintDetected || + (predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope))) + ); const followupDiscoverySeedApplicable = Boolean( followupSeed.domain && !rawLifecycleSignal && - !rawValueFlowSignal && - (monthlyAggregationSignal || - explicitDateScopeLiteralDetected || - predecomposeDateScope || - explicitOrganizationScopeSignal || - organizationClarificationFollowupApplicable) + !rawMetadataSignal && + (periodClarificationFollowupApplicable || + (!rawValueFlowSignal && + (monthlyAggregationSignal || + rawAllTimeScopeSignal || + explicitDateScopeLiteralDetected || + predecomposeDateScope || + explicitOrganizationScopeSignal || + organizationClarificationFollowupApplicable))) ); const metadataFollowupSeedApplicable = Boolean( followupSeed.domain === "metadata" && @@ -1563,6 +1578,7 @@ export function buildAssistantMcpDiscoveryTurnInput( forceDiscoveryOverExplicitIntent: Boolean(entityResolutionClarificationCandidate) || organizationClarificationFollowupApplicable || + periodClarificationFollowupApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || @@ -1616,6 +1632,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (organizationClarificationFollowupApplicable) { pushReason(reasonCodes, "mcp_discovery_organization_clarification_followup_from_followup_context"); } + if (periodClarificationFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_period_clarification_followup_from_followup_context"); + } if (payoutSignal) { pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); } diff --git a/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts index 54dfd45..91e707b 100644 --- a/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpCatalogIndex.test.ts @@ -71,6 +71,17 @@ describe("assistant MCP catalog index", () => { expect(primitives).toEqual(["resolve_entity_reference", "query_documents", "probe_coverage"]); }); + it("treats all-time organization-scoped value-flow as catalog-compatible without inventing a fake period axis", () => { + const primitives = searchAssistantMcpCatalogPrimitivesByFactAxis({ + business_fact_family: "value_flow", + action_family: "turnover", + has_subject_candidates: false, + required_axes: ["organization", "all_time_scope", "aggregate_axis", "amount", "coverage_target"] + }); + + expect(primitives).toEqual(["query_movements", "aggregate_by_axis", "probe_coverage"]); + }); + it("can search reviewed primitives directly from a confirmed document metadata surface", () => { const primitives = searchAssistantMcpCatalogPrimitivesByMetadataSurface({ downstream_route_family: "document_evidence", @@ -150,6 +161,25 @@ describe("assistant MCP catalog index", () => { ]); }); + it("marks an all-time organization-scoped value-flow plan as catalog-compatible without requiring an explicit period", () => { + const plan = buildAssistantMcpDiscoveryPlan({ + semanticDataNeed: "organization-scoped value-flow evidence", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: "ООО Альтернатива Плюс" + }, + proposedPrimitives: ["query_movements", "aggregate_by_axis", "probe_coverage"], + requiredAxes: ["organization", "all_time_scope", "aggregate_axis", "amount", "coverage_target"] + }); + + const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan); + + expect(review.review_status).toBe("catalog_compatible"); + expect(review.reason_codes).toContain("catalog_plan_compatible"); + expect(review.missing_axes_by_primitive).toEqual({}); + }); + it("preserves source-summary evidence floors for metadata and coverage primitives", () => { expect(getAssistantMcpCatalogPrimitive("inspect_1c_metadata").evidence_floor).toBe("source_summary"); expect(getAssistantMcpCatalogPrimitive("probe_coverage").evidence_floor).toBe("source_summary"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts index b94835a..2ab8951 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts @@ -182,6 +182,29 @@ describe("assistant MCP discovery data need graph", () => { expect(result.reason_codes).toContain("data_need_graph_open_scope_total_needs_organization"); }); + it("treats colloquial open incoming total wording with filler words as an open-scope ask rather than a missing subject", () => { + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "counterparty value-flow evidence", + rawUtterance: "сколько вообще входящих денег было?", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover" + } + }); + + expect(result.business_fact_family).toBe("value_flow"); + expect(result.subject_candidates).toEqual([]); + expect(result.clarification_gaps).toEqual(["organization", "period"]); + expect(result.proof_expectation).toBe("clarification_required"); + expect(result.decomposition_candidates).toEqual([ + "collect_scoped_movements", + "aggregate_checked_amounts", + "probe_coverage" + ]); + expect(result.reason_codes).toContain("data_need_graph_open_scope_total_without_subject"); + expect(result.reason_codes).toContain("data_need_graph_open_scope_total_needs_organization"); + }); + it("treats all-time open-scope totals as an open-ended period rather than a missing period", () => { const result = buildAssistantMcpDiscoveryDataNeedGraph({ semanticDataNeed: "counterparty value-flow evidence", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index b00884a..f828ded 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -779,4 +779,48 @@ describe("assistant MCP discovery planner", () => { expect(result.reason_codes).toContain("planner_selected_open_scope_value_flow_total_from_data_need_graph"); expect(result.selected_chain_id).not.toBe("entity_resolution"); }); + + it("treats all-time organization-scoped open totals as execution-ready without reintroducing a fake period gap", () => { + const result = 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: "all_time_scope", + comparison_need: null, + ranking_need: null, + proof_expectation: "coverage_checked_fact", + clarification_gaps: [], + decomposition_candidates: ["collect_scoped_movements", "aggregate_checked_amounts", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_open_scope_total_without_subject", + "data_need_graph_all_time_scope_hint" + ] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: "ООО Альтернатива Плюс" + } + }); + + expect(result.planner_status).toBe("ready_for_execution"); + expect(result.semantic_data_need).toBe("organization-scoped value-flow evidence"); + expect(result.selected_chain_id).toBe("value_flow"); + expect(result.proposed_primitives).toEqual(["query_movements", "aggregate_by_axis", "probe_coverage"]); + expect(result.required_axes).toEqual([ + "organization", + "all_time_scope", + "aggregate_axis", + "amount", + "coverage_target" + ]); + expect(result.catalog_review.review_status).toBe("catalog_compatible"); + expect(result.reason_codes).toContain("planner_selected_open_scope_value_flow_total_from_data_need_graph"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 41f922c..0351d93 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1500,6 +1500,27 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_open_scope_total_needs_organization"); }); + it("treats colloquial open incoming total wording with filler words as an open-scope ask that needs organization and period", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "сколько вообще входящих денег было?" + }); + + 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", + 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?.subject_candidates).toEqual([]); + expect(result.data_need_graph?.clarification_gaps).toEqual(["organization", "period"]); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_open_scope_total_without_subject"); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_open_scope_total_needs_organization"); + }); + it("resumes an open-scope incoming total from follow-up context when the user clarifies only the organization", () => { const orgName = "ООО Альтернатива Плюс"; const result = buildAssistantMcpDiscoveryTurnInput({ @@ -1564,6 +1585,46 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_all_time_scope_hint"); }); + it("resumes an open-scope total clarification loop from saved state when the user resolves the pending period with all-time wording", () => { + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "за все время", + assistantTurnMeaning: { + explicit_intent_candidate: "customer_revenue_and_payments" + }, + followupContext: { + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "value_flow", + previous_discovery_loop_pending_axes: ["period"], + previous_discovery_loop_provided_axes: ["organization", "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_filters: { + organization: orgName + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.reason_codes).toContain("mcp_discovery_all_time_scope_signal_detected"); + expect(result.reason_codes).toContain("mcp_discovery_period_clarification_followup_from_followup_context"); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: orgName, + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined(); + expect(result.data_need_graph?.clarification_gaps).toEqual([]); + expect(result.data_need_graph?.time_scope_need).toBe("all_time_scope"); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_open_scope_total_without_subject"); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_all_time_scope_hint"); + }); + it("resumes an open-scope ranking from follow-up context when the user clarifies only the organization", () => { const orgName = "РћРћРћ Альтернатива Плюс"; const result = buildAssistantMcpDiscoveryTurnInput({