From 8a0a4f09224ac4d36fb7c46e0fac576309249322 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 22 Apr 2026 21:58:01 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20open-scope=20continuity=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20org=20clarification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._phase35_open_scope_org_clarification.json | 75 +++++++++++++ ...e_year_switch_after_org_clarification.json | 99 +++++++++++++++++ ...en_scope_comparison_org_clarification.json | 77 ++++++++++++++ .../assistantMcpDiscoveryAnswerAdapter.js | 40 +++++++ .../assistantMcpDiscoveryDataNeedGraph.js | 44 +++++++- .../services/assistantMcpDiscoveryPlanner.js | 33 +++++- .../assistantMcpDiscoveryResponsePolicy.js | 20 ++++ .../assistantMcpDiscoveryTurnInputAdapter.js | 46 +++++++- .../assistantMcpDiscoveryAnswerAdapter.ts | 46 ++++++++ .../assistantMcpDiscoveryDataNeedGraph.ts | 58 +++++++++- .../services/assistantMcpDiscoveryPlanner.ts | 55 ++++++++-- .../assistantMcpDiscoveryResponsePolicy.ts | 30 ++++++ .../assistantMcpDiscoveryTurnInputAdapter.ts | 53 +++++++++- ...assistantMcpDiscoveryDataNeedGraph.test.ts | 23 ++++ .../assistantMcpDiscoveryPlanner.test.ts | 38 +++++++ ...ssistantMcpDiscoveryResponsePolicy.test.ts | 46 ++++++++ ...assistantMcpDiscoveryRuntimeBridge.test.ts | 38 +++++++ ...stantMcpDiscoveryRuntimeEntryPoint.test.ts | 100 ++++++++++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 89 ++++++++++++++++ 19 files changed, 981 insertions(+), 29 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase35_open_scope_org_clarification.json create mode 100644 docs/orchestration/address_truth_harness_phase36_open_scope_year_switch_after_org_clarification.json create mode 100644 docs/orchestration/address_truth_harness_phase37_open_scope_comparison_org_clarification.json diff --git a/docs/orchestration/address_truth_harness_phase35_open_scope_org_clarification.json b/docs/orchestration/address_truth_harness_phase35_open_scope_org_clarification.json new file mode 100644 index 0000000..08773b1 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase35_open_scope_org_clarification.json @@ -0,0 +1,75 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase35_open_scope_org_clarification", + "domain": "address_phase35_open_scope_org_clarification", + "title": "Phase 35 open-scope organization clarification replay", + "description": "Targeted AGENT replay for Big Block D where a generic one-sided money total must ask for organization, then continue the same bounded open-scope value-flow chain after the user clarifies only the organization.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_generic_incoming_total_requires_organization", + "title": "Generic incoming total asks for organization instead of inventing a counterparty", + "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_total", "incoming", "open_scope", "organization_clarification", "bounded_autonomy"] + }, + { + "step_id": "step_02_org_clarification_resumes_incoming_total", + "title": "Organization-only clarification resumes the same open-scope incoming total chain", + "question": "по ООО Альтернатива Плюс", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2020", + "(?i)входящ|получ|поступ", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)альтернатива", + "(?i)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_total", "incoming", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_outgoing_followup_reuses_org_and_period", + "title": "Short outgoing follow-up reuses the same organization and period without reintroducing a counterparty", + "question": "а исходящих?", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2020", + "(?i)исходящ|заплат|списан|платеж", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)альтернатива", + "(?i)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента", + "(?i)уточните организацию" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_total", "outgoing", "organization_followup_reuse", "bounded_autonomy"] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase36_open_scope_year_switch_after_org_clarification.json b/docs/orchestration/address_truth_harness_phase36_open_scope_year_switch_after_org_clarification.json new file mode 100644 index 0000000..8589c31 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase36_open_scope_year_switch_after_org_clarification.json @@ -0,0 +1,99 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase36_open_scope_year_switch_after_org_clarification", + "domain": "address_phase36_open_scope_year_switch_after_org_clarification", + "title": "Phase 36 open-scope year switch after organization clarification", + "description": "Targeted AGENT replay for Big Block D where an open-scope value-flow total asks for organization, then preserves the same organization and money-flow contour across a short year-switch follow-up and a one-sided outgoing follow-up.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_generic_incoming_total_requires_organization", + "title": "Generic incoming total asks for organization before execution", + "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_total", "incoming", "open_scope", "organization_clarification", "bounded_autonomy"] + }, + { + "step_id": "step_02_org_clarification_resumes_incoming_total", + "title": "Organization-only clarification resumes the same incoming total 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)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_total", "incoming", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_year_switch_reuses_org_and_axis", + "title": "Short year-switch follow-up keeps the same organization and incoming 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)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента", + "(?i)уточните организацию" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_total", "incoming", "year_switch", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_04_outgoing_followup_reuses_org_and_new_year", + "title": "Outgoing follow-up keeps the same organization and already switched year", + "question": "а исходящих?", + "allowed_reply_types": ["factual_with_explanation", "partial_coverage"], + "required_answer_patterns_all": [ + "(?i)2021", + "(?i)исходящ|заплат|списан|платеж", + "(?i)руб" + ], + "required_answer_patterns_any": [ + "(?i)альтернатива", + "(?i)проверенн|найденн" + ], + "forbidden_answer_patterns": [ + "(?i)уточните контрагента", + "(?i)не найден контрагент", + "(?i)по какому контрагенту", + "(?i)не найдено контрагента", + "(?i)уточните организацию" + ], + "criticality": "critical", + "semantic_tags": ["value_flow_total", "outgoing", "year_switch", "organization_followup_reuse", "bounded_autonomy"] + } + ] +} diff --git a/docs/orchestration/address_truth_harness_phase37_open_scope_comparison_org_clarification.json b/docs/orchestration/address_truth_harness_phase37_open_scope_comparison_org_clarification.json new file mode 100644 index 0000000..93d10ba --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase37_open_scope_comparison_org_clarification.json @@ -0,0 +1,77 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase37_open_scope_comparison_org_clarification", + "domain": "address_phase37_open_scope_comparison_org_clarification", + "title": "Phase 37 open-scope comparison organization clarification", + "description": "Targeted AGENT replay for Big Block D where an open-scope incoming-vs-outgoing comparison must ask for organization, then resume the same comparison contour after an organization-only clarification and preserve it across a short year switch.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_open_scope_comparison_requires_organization", + "title": "Generic incoming-vs-outgoing comparison 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_comparison", "open_scope", "organization_clarification", "bounded_autonomy"] + }, + { + "step_id": "step_02_org_clarification_resumes_comparison", + "title": "Organization-only clarification resumes the same comparison 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_comparison", "organization_followup_reuse", "bounded_autonomy"] + }, + { + "step_id": "step_03_year_switch_reuses_org_and_comparison", + "title": "Short year-switch follow-up keeps the same organization and comparison 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_comparison", "year_switch", "organization_followup_reuse", "bounded_autonomy"] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 4131815..3535610 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -127,6 +127,14 @@ function explicitDateScope(pilot) { const normalized = value.trim(); return normalized.length > 0 ? normalized : null; } +function explicitOrganizationScope(pilot) { + const value = pilot.evidence.query_plan.turn_meaning_ref?.explicit_organization_scope; + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} function documentOrMovementScopeRu(pilot) { const entity = firstEntityCandidate(pilot); const period = explicitDateScope(pilot); @@ -151,6 +159,12 @@ function isBidirectionalValueFlowComparisonClarification(pilot) { return (pilot.reason_codes.includes("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph") || pilot.dry_run.reason_codes.includes("planner_selected_bidirectional_value_flow_comparison_from_data_need_graph")); } +function isOpenScopeValueFlowClarification(pilot) { + return (pilot.reason_codes.includes("planner_selected_open_scope_value_flow_total_from_data_need_graph") || + pilot.reason_codes.includes("planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph") || + pilot.dry_run.reason_codes.includes("planner_selected_open_scope_value_flow_total_from_data_need_graph") || + pilot.dry_run.reason_codes.includes("planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph")); +} function isDocumentLaneClarification(pilot) { return (isDocumentPilot(pilot) || pilot.reason_codes.includes("planner_selected_document_recipe") || @@ -172,6 +186,16 @@ function dryRunMissingAxis(pilot, axis) { return pilot.dry_run.execution_steps.some((step) => step.missing_axis_options.some((option) => option.includes(axis))); } function clarificationNeedRu(pilot) { + 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) { + return { + subject: "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e", + verb: "\u043d\u0443\u0436\u043d\u043e" + }; + } const hasCounterparty = dryRunHasAxis(pilot, "counterparty"); const hasAccount = dryRunHasAxis(pilot, "account"); const needsPeriod = dryRunMissingAxis(pilot, "period"); @@ -188,9 +212,16 @@ function clarificationNeedRu(pilot) { return { subject: "контекст проверки", verb: "нужно" }; } function clarificationNextStepLine(pilot, laneLabel) { + 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"); const needsPeriod = dryRunMissingAxis(pilot, "period"); const needsOrganization = dryRunMissingAxis(pilot, "organization"); const scopeSuffix = laneScopeSuffix(pilot); + if (organizationScopedOpenTotal && !needsPeriod) { + return `Уточните организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`; + } if (needsPeriod && needsOrganization) { return `Уточните период и организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`; } @@ -298,6 +329,10 @@ function headlineFor(mode, pilot) { const need = clarificationNeedRu(pilot); return `Могу посчитать ranking по денежному потоку между контрагентами, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; } + if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { + const need = clarificationNeedRu(pilot); + return `Могу посчитать общий денежный поток в проверяемом окне, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + } if (mode === "needs_clarification") { return "Нужно уточнить контекст перед поиском в 1С."; } @@ -337,6 +372,9 @@ function nextStepFor(mode, pilot) { if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) { return clarificationNextStepLine(pilot, "ranking-поиску между контрагентами"); } + if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { + return clarificationNextStepLine(pilot, "денежному потоку"); + } if (mode === "needs_clarification") { return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С."; } @@ -541,6 +579,8 @@ function derivedValueFlowConfirmedLine(pilot) { if (!flow) { return null; } + const organizationScope = explicitOrganizationScope(pilot); + const organization = organizationScope ? ` по организации ${organizationScope}` : ""; const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : ""; const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне"; const movementLabel = flow.value_flow_direction === "outgoing_supplier_payout" diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index b7c652c..7eb945b 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -85,6 +85,30 @@ function comparisonNeedFor(action) { } return null; } +function hasOpenScopeOneSidedValueTotalHint(rawUtterance, action) { + if (!rawUtterance) { + return false; + } + if (action === "turnover") { + return /(?:\bсколько\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 false; +} +function hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action) { + if (!rawUtterance) { + 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); + } + 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 false; +} function supportsOrganizationScopedOpenTotal(action) { return action === "turnover" || action === "payout"; } @@ -95,7 +119,7 @@ function allowsOpenScopeWithoutSubject(input) { if (input.rankingNeed || input.comparisonNeed === "incoming_vs_outgoing") { return true; } - return Boolean(input.organizationScope && supportsOrganizationScopedOpenTotal(input.action)); + return Boolean(supportsOrganizationScopedOpenTotal(input.action) && (input.organizationScope || input.oneSidedOpenScopeTotalHint)); } function rankingNeedFromRawUtterance(value) { const text = lower(value); @@ -229,19 +253,30 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const aggregationNeed = aggregationNeedFor(aggregationAxis); const comparisonNeed = comparisonNeedFor(action); const rankingNeed = rankingNeedFromRawUtterance(rawUtterance); + const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action); const openScopeWithoutSubject = subjectCandidates.length === 0 && allowsOpenScopeWithoutSubject({ family: businessFactFamily, action, organizationScope: explicitOrganizationScope, comparisonNeed, - rankingNeed + rankingNeed, + oneSidedOpenScopeTotalHint }); const clarificationGaps = []; if (unsupported === "metadata_lane_choice_clarification" || action === "resolve_next_lane") { pushUnique(clarificationGaps, "lane_family_choice"); } - if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) { + if (subjectCandidates.length === 0 && + businessFactFamily === "value_flow" && + openScopeWithoutSubject && + !rankingNeed && + !comparisonNeed && + oneSidedOpenScopeTotalHint && + !explicitOrganizationScope) { + pushUnique(clarificationGaps, "organization"); + } + else if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) { pushUnique(clarificationGaps, "subject"); } const timeScopeNeed = timeScopeNeedFor({ @@ -279,6 +314,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { if (openScopeWithoutSubject && !rankingNeed && !comparisonNeed) { pushReason(reasonCodes, "data_need_graph_open_scope_total_without_subject"); } + if (clarificationGaps.includes("organization")) { + pushReason(reasonCodes, "data_need_graph_open_scope_total_needs_organization"); + } if (clarificationGaps.length > 0) { pushReason(reasonCodes, "data_need_graph_has_clarification_gaps"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js index 24f9d6a..916c68a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPlanner.js @@ -41,6 +41,9 @@ function hasEntity(meaning) { function hasSubjectCandidates(graph) { return (graph?.subject_candidates.length ?? 0) > 0; } +function hasReasonCode(graph, reasonCode) { + return (graph?.reason_codes ?? []).includes(reasonCode); +} function aggregationAxis(meaning) { return toNonEmptyString(meaning?.asked_aggregation_axis)?.toLowerCase() ?? null; } @@ -87,6 +90,9 @@ function recipeFor(input) { const graphAggregation = lower(dataNeedGraph?.aggregation_need); const graphClarificationGaps = (dataNeedGraph?.clarification_gaps ?? []).map((item) => lower(item)); const organizationScope = toNonEmptyString(meaning?.explicit_organization_scope); + const openScopeTotalWithoutSubject = graphFactFamily === "value_flow" && + !hasSubjectCandidates(dataNeedGraph) && + hasReasonCode(dataNeedGraph, "data_need_graph_open_scope_total_without_subject"); const combined = `${domain} ${action} ${unsupported}`.trim(); const axes = []; const requestedAggregationAxis = aggregationAxis(meaning); @@ -133,7 +139,8 @@ function recipeFor(input) { : "planner_selected_top_ranked_value_flow_from_data_need_graph" }; } - if (!hasSubjectCandidates(dataNeedGraph) && organizationScope) { + if (openScopeTotalWithoutSubject) { + pushUnique(axes, "organization"); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -354,7 +361,27 @@ function planAssistantMcpDiscovery(input) { maxProbeCount: budgetOverride.maxProbeCount }); const review = (0, assistantMcpCatalogIndex_1.reviewAssistantMcpDiscoveryPlanAgainstCatalog)(plan); - const plannerStatus = statusFrom(plan, review); + const organizationClarificationRequired = (dataNeedGraph?.clarification_gaps ?? []).includes("organization") && + !toNonEmptyString(input.turnMeaning?.explicit_organization_scope); + const adjustedReview = organizationClarificationRequired && recipe.primitives.includes("query_movements") + ? { + ...review, + review_status: "needs_more_axes", + missing_axes_by_primitive: { + ...review.missing_axes_by_primitive, + query_movements: review.missing_axes_by_primitive.query_movements?.length + ? review.missing_axes_by_primitive.query_movements + : [["organization"]] + }, + reason_codes: review.reason_codes.includes("catalog_requires_organization_scope_from_data_need_graph") + ? review.reason_codes + : [...review.reason_codes, "catalog_requires_organization_scope_from_data_need_graph"] + } + : review; + const plannerStatus = organizationClarificationRequired ? "needs_clarification" : statusFrom(plan, adjustedReview); + if (organizationClarificationRequired) { + pushReason(reasonCodes, "planner_requires_organization_scope_from_data_need_graph"); + } if (plannerStatus === "ready_for_execution") { pushReason(reasonCodes, "planner_ready_for_guarded_mcp_execution"); } @@ -375,7 +402,7 @@ function planAssistantMcpDiscovery(input) { proposed_primitives: recipe.primitives, required_axes: recipe.axes, discovery_plan: plan, - catalog_review: review, + catalog_review: adjustedReview, reason_codes: reasonCodes }; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index a7f4eb2..ba04222 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -138,6 +138,19 @@ function readDiscoveryTurnMeaning(entryPoint) { const turnInput = toRecordObject(entryPoint?.turn_input); return toRecordObject(turnInput?.turn_meaning_ref); } +function readDiscoveryDataNeedGraph(entryPoint) { + const turnInput = toRecordObject(entryPoint?.turn_input); + return toRecordObject(turnInput?.data_need_graph); +} +function isOpenScopeValueFlowWithoutSubject(entryPoint) { + const graph = readDiscoveryDataNeedGraph(entryPoint); + const businessFactFamily = toNonEmptyString(graph?.business_fact_family); + const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : []; + const reasonCodes = Array.isArray(graph?.reason_codes) ? graph.reason_codes : []; + return (businessFactFamily === "value_flow" && + subjectCandidates.length === 0 && + reasonCodes.some((reason) => toNonEmptyString(reason) === "data_need_graph_open_scope_total_without_subject")); +} function readTruthAnswerShape(input) { const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); if (directShape) { @@ -197,6 +210,9 @@ function hasAlignedFactualAddressReply(input, entryPoint) { if (!hasEffectivelyFactualAddressReply(input)) { return false; } + if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); return isDetectedIntentAlignedWithTurnMeaning(detectedIntent, readDiscoveryTurnMeaning(entryPoint)); } @@ -218,6 +234,10 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) { if (!detectedIntent || (!askedDomain && !askedAction && !unsupportedFamily)) { return false; } + if (detectedIntent === "customer_revenue_and_payments" && + isOpenScopeValueFlowWithoutSubject(entryPoint)) { + return true; + } return !isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning); } function hasMatchedFactualAddressContinuationTarget(input, entryPoint) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 15bbbef..1e892b5 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -313,6 +313,17 @@ function hasOrganizationScopeSignalUtf8(text) { /\b(?:llc|inc|corp|company|organization|organisation)\b/iu.test(text) || /(?:\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043c\u043f\u0430\u043d)/iu.test(text)); } +function extractOrganizationScopeFromRawText(value) { + const text = toNonEmptyString(value); + if (!text) { + return null; + } + const match = text.match(/(?:^|[\s,;:])(?:\u043f\u043e|for|in|within)?\s*((?:\u041e\u041e\u041e|\u0418\u041f|\u0410\u041e|\u041f\u0410\u041e|\u0417\u0410\u041e|LLC|Inc|LTD|Corp)\s+[^\n,.;:!?]+)/u); + if (!match?.[1]) { + return null; + } + return toNonEmptyString(match[1]); +} function hasMonthlyAggregationSignal(text) { return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test(text); } @@ -590,12 +601,25 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope); 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 organizationClarificationFollowupApplicable = Boolean(followupSeed.domain === "counterparty_value" && + !followupSeed.counterparty && + rawOrganizationMentionSignal && + (rawOrganizationScope || followupSeed.organization) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal); const rawOpenScopeValueFlowOrganizationSignal = Boolean(rawValueFlowSignal && !rawBidirectionalValueFlowSignal && - hasOrganizationScopeSignalUtf8(rawText) && - (predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope)); + explicitOrganizationScopeSignal); const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization); - const organizationMirrorsPredecomposeCounterparty = Boolean((rawBidirectionalValueFlowSignal || hasValueRankingSignal(rawText) || rawOpenScopeValueFlowOrganizationSignal) && + const organizationMirrorsPredecomposeCounterparty = Boolean((rawBidirectionalValueFlowSignal || + hasValueRankingSignal(rawText) || + rawOpenScopeValueFlowOrganizationSignal || + explicitOrganizationScopeSignal) && (sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) || predecomposeOrganizationMirrorsCounterparty)); const normalizedPredecomposeCounterparty = organizationMirrorsPredecomposeCounterparty @@ -605,7 +629,11 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const followupDiscoverySeedApplicable = Boolean(followupSeed.domain && !rawLifecycleSignal && !rawValueFlowSignal && - (monthlyAggregationSignal || explicitDateScopeLiteralDetected || predecomposeDateScope)); + (monthlyAggregationSignal || + explicitDateScopeLiteralDetected || + predecomposeDateScope || + explicitOrganizationScopeSignal || + organizationClarificationFollowupApplicable)); const metadataFollowupSeedApplicable = Boolean(followupSeed.domain === "metadata" && !rawLifecycleSignal && !rawValueFlowSignal && @@ -844,13 +872,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) { Boolean(bidirectionalValueFlowSignal || hasValueRankingSignal(rawText) || rawOpenScopeValueFlowOrganizationSignal || + explicitOrganizationScopeSignal || followupSeed.organization); if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) { pushUnique(entityCandidates, predecomposeEntities.organization); pushUnique(entityCandidates, followupSeed.organization); } const explicitOrganizationScope = valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty - ? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization + ? rawOrganizationScope ?? + predecomposeEntities.organization ?? + assistantTurnMeaningOrganizationScope ?? + followupSeed.organization : null; if (valueFlowOrganizationStaysScope && explicitOrganizationScope) { for (let index = entityCandidates.length - 1; index >= 0; index -= 1) { @@ -976,6 +1008,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { metadataGroundedDocumentLaneApplicable || groundedValueFlowFollowupApplicable, forceDiscoveryOverExplicitIntent: Boolean(entityResolutionClarificationCandidate) || + organizationClarificationFollowupApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || @@ -1025,6 +1058,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (entityResolutionClarificationCandidate) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarification_candidate_selected"); } + if (organizationClarificationFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_organization_clarification_followup_from_followup_context"); + } if (payoutSignal) { pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 8407d09..498272c 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -174,6 +174,15 @@ function explicitDateScope(pilot: AssistantMcpDiscoveryPilotExecutionContract): return normalized.length > 0 ? normalized : null; } +function explicitOrganizationScope(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + const value = pilot.evidence.query_plan.turn_meaning_ref?.explicit_organization_scope; + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + function documentOrMovementScopeRu(pilot: AssistantMcpDiscoveryPilotExecutionContract): string { const entity = firstEntityCandidate(pilot); const period = explicitDateScope(pilot); @@ -210,6 +219,15 @@ function isBidirectionalValueFlowComparisonClarification( ); } +function isOpenScopeValueFlowClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { + return ( + pilot.reason_codes.includes("planner_selected_open_scope_value_flow_total_from_data_need_graph") || + pilot.reason_codes.includes("planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph") || + pilot.dry_run.reason_codes.includes("planner_selected_open_scope_value_flow_total_from_data_need_graph") || + pilot.dry_run.reason_codes.includes("planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph") + ); +} + function isDocumentLaneClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { return ( isDocumentPilot(pilot) || @@ -241,6 +259,17 @@ function dryRunMissingAxis(pilot: AssistantMcpDiscoveryPilotExecutionContract, a function clarificationNeedRu( pilot: AssistantMcpDiscoveryPilotExecutionContract ): { subject: string; verb: string } { + 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) { + return { + subject: "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e", + verb: "\u043d\u0443\u0436\u043d\u043e" + }; + } const hasCounterparty = dryRunHasAxis(pilot, "counterparty"); const hasAccount = dryRunHasAxis(pilot, "account"); const needsPeriod = dryRunMissingAxis(pilot, "period"); @@ -261,9 +290,17 @@ function clarificationNextStepLine( pilot: AssistantMcpDiscoveryPilotExecutionContract, laneLabel: string ): string { + 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"); const needsPeriod = dryRunMissingAxis(pilot, "period"); const needsOrganization = dryRunMissingAxis(pilot, "organization"); const scopeSuffix = laneScopeSuffix(pilot); + if (organizationScopedOpenTotal && !needsPeriod) { + return `Уточните организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`; + } if (needsPeriod && needsOrganization) { return `Уточните период и организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`; } @@ -378,6 +415,10 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD const need = clarificationNeedRu(pilot); return `Могу посчитать ranking по денежному потоку между контрагентами, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; } + if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { + const need = clarificationNeedRu(pilot); + return `Могу посчитать общий денежный поток в проверяемом окне, но для bounded поиска в 1С ${need.verb} ${need.subject}.`; + } if (mode === "needs_clarification") { return "Нужно уточнить контекст перед поиском в 1С."; } @@ -422,6 +463,9 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) { return clarificationNextStepLine(pilot, "ranking-поиску между контрагентами"); } + if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) { + return clarificationNextStepLine(pilot, "денежному потоку"); + } if (mode === "needs_clarification") { return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С."; } @@ -646,6 +690,8 @@ function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutio if (!flow) { return null; } + const organizationScope = explicitOrganizationScope(pilot); + const organization = organizationScope ? ` по организации ${organizationScope}` : ""; const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : ""; const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне"; const movementLabel = diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index 25e985c..c3258e3 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -132,6 +132,40 @@ function comparisonNeedFor(action: string): string | null { return null; } +function hasOpenScopeOneSidedValueTotalHint(rawUtterance: string, action: string): boolean { + if (!rawUtterance) { + return false; + } + if (action === "turnover") { + return /(?:\bсколько\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 false; +} + +function hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance: string, action: string): boolean { + if (!rawUtterance) { + 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 + ); + } + 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 false; +} + function supportsOrganizationScopedOpenTotal(action: string): boolean { return action === "turnover" || action === "payout"; } @@ -142,6 +176,7 @@ function allowsOpenScopeWithoutSubject(input: { organizationScope: string | null; comparisonNeed: string | null; rankingNeed: string | null; + oneSidedOpenScopeTotalHint: boolean; }): boolean { if (input.family !== "value_flow") { return false; @@ -149,7 +184,9 @@ function allowsOpenScopeWithoutSubject(input: { if (input.rankingNeed || input.comparisonNeed === "incoming_vs_outgoing") { return true; } - return Boolean(input.organizationScope && supportsOrganizationScopedOpenTotal(input.action)); + return Boolean( + supportsOrganizationScopedOpenTotal(input.action) && (input.organizationScope || input.oneSidedOpenScopeTotalHint) + ); } function rankingNeedFromRawUtterance(value: string): string | null { @@ -303,6 +340,7 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const aggregationNeed = aggregationNeedFor(aggregationAxis); const comparisonNeed = comparisonNeedFor(action); const rankingNeed = rankingNeedFromRawUtterance(rawUtterance); + const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action); const openScopeWithoutSubject = subjectCandidates.length === 0 && allowsOpenScopeWithoutSubject({ @@ -310,13 +348,24 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( action, organizationScope: explicitOrganizationScope, comparisonNeed, - rankingNeed + rankingNeed, + oneSidedOpenScopeTotalHint }); const clarificationGaps: string[] = []; if (unsupported === "metadata_lane_choice_clarification" || action === "resolve_next_lane") { pushUnique(clarificationGaps, "lane_family_choice"); } - if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) { + if ( + subjectCandidates.length === 0 && + businessFactFamily === "value_flow" && + openScopeWithoutSubject && + !rankingNeed && + !comparisonNeed && + oneSidedOpenScopeTotalHint && + !explicitOrganizationScope + ) { + pushUnique(clarificationGaps, "organization"); + } else if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) { pushUnique(clarificationGaps, "subject"); } const timeScopeNeed = timeScopeNeedFor({ @@ -353,6 +402,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( if (openScopeWithoutSubject && !rankingNeed && !comparisonNeed) { pushReason(reasonCodes, "data_need_graph_open_scope_total_without_subject"); } + if (clarificationGaps.includes("organization")) { + pushReason(reasonCodes, "data_need_graph_open_scope_total_needs_organization"); + } if (clarificationGaps.length > 0) { pushReason(reasonCodes, "data_need_graph_has_clarification_gaps"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts index a5cff6d..335de78 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPlanner.ts @@ -102,6 +102,13 @@ function hasSubjectCandidates(graph: AssistantMcpDiscoveryDataNeedGraphContract return (graph?.subject_candidates.length ?? 0) > 0; } +function hasReasonCode( + graph: AssistantMcpDiscoveryDataNeedGraphContract | null | undefined, + reasonCode: string +): boolean { + return (graph?.reason_codes ?? []).includes(reasonCode); +} + function aggregationAxis(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): string | null { return toNonEmptyString(meaning?.asked_aggregation_axis)?.toLowerCase() ?? null; } @@ -154,6 +161,10 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { const graphAggregation = lower(dataNeedGraph?.aggregation_need); const graphClarificationGaps = (dataNeedGraph?.clarification_gaps ?? []).map((item) => lower(item)); const organizationScope = toNonEmptyString(meaning?.explicit_organization_scope); + const openScopeTotalWithoutSubject = + graphFactFamily === "value_flow" && + !hasSubjectCandidates(dataNeedGraph) && + hasReasonCode(dataNeedGraph, "data_need_graph_open_scope_total_without_subject"); const combined = `${domain} ${action} ${unsupported}`.trim(); const axes: string[] = []; const requestedAggregationAxis = aggregationAxis(meaning); @@ -205,7 +216,8 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe { : "planner_selected_top_ranked_value_flow_from_data_need_graph" }; } - if (!hasSubjectCandidates(dataNeedGraph) && organizationScope) { + if (openScopeTotalWithoutSubject) { + pushUnique(axes, "organization"); pushUnique(axes, "aggregate_axis"); pushUnique(axes, "amount"); pushUnique(axes, "coverage_target"); @@ -451,7 +463,30 @@ export function planAssistantMcpDiscovery( maxProbeCount: budgetOverride.maxProbeCount }); const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan); - const plannerStatus = statusFrom(plan, review); + const organizationClarificationRequired = + (dataNeedGraph?.clarification_gaps ?? []).includes("organization") && + !toNonEmptyString(input.turnMeaning?.explicit_organization_scope); + const adjustedReview = + organizationClarificationRequired && recipe.primitives.includes("query_movements") + ? { + ...review, + review_status: "needs_more_axes" as const, + missing_axes_by_primitive: { + ...review.missing_axes_by_primitive, + query_movements: review.missing_axes_by_primitive.query_movements?.length + ? review.missing_axes_by_primitive.query_movements + : [["organization"]] + }, + reason_codes: review.reason_codes.includes("catalog_requires_organization_scope_from_data_need_graph") + ? review.reason_codes + : [...review.reason_codes, "catalog_requires_organization_scope_from_data_need_graph"] + } + : review; + const plannerStatus = organizationClarificationRequired ? "needs_clarification" : statusFrom(plan, adjustedReview); + + if (organizationClarificationRequired) { + pushReason(reasonCodes, "planner_requires_organization_scope_from_data_need_graph"); + } if (plannerStatus === "ready_for_execution") { pushReason(reasonCodes, "planner_ready_for_guarded_mcp_execution"); @@ -467,12 +502,12 @@ export function planAssistantMcpDiscovery( planner_status: plannerStatus, semantic_data_need: semanticDataNeed, data_need_graph: dataNeedGraph, - selected_chain_id: recipe.chainId, - selected_chain_summary: recipe.chainSummary, - proposed_primitives: recipe.primitives, - required_axes: recipe.axes, - discovery_plan: plan, - catalog_review: review, - reason_codes: reasonCodes - }; + selected_chain_id: recipe.chainId, + selected_chain_summary: recipe.chainSummary, + proposed_primitives: recipe.primitives, + required_axes: recipe.axes, + discovery_plan: plan, + catalog_review: adjustedReview, + reason_codes: reasonCodes + }; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index c1bd30d..c3ff343 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -214,6 +214,27 @@ function readDiscoveryTurnMeaning( return toRecordObject(turnInput?.turn_meaning_ref); } +function readDiscoveryDataNeedGraph( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): Record | null { + const turnInput = toRecordObject(entryPoint?.turn_input); + return toRecordObject(turnInput?.data_need_graph); +} + +function isOpenScopeValueFlowWithoutSubject( + 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 reasonCodes = Array.isArray(graph?.reason_codes) ? graph.reason_codes : []; + return ( + businessFactFamily === "value_flow" && + subjectCandidates.length === 0 && + reasonCodes.some((reason) => toNonEmptyString(reason) === "data_need_graph_open_scope_total_without_subject") + ); +} + function readTruthAnswerShape(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): Record | null { const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract); if (directShape) { @@ -286,6 +307,9 @@ function hasAlignedFactualAddressReply( if (!hasEffectivelyFactualAddressReply(input)) { return false; } + if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); return isDetectedIntentAlignedWithTurnMeaning(detectedIntent, readDiscoveryTurnMeaning(entryPoint)); } @@ -311,6 +335,12 @@ function hasSemanticConflictWithDiscoveryTurnMeaning( if (!detectedIntent || (!askedDomain && !askedAction && !unsupportedFamily)) { return false; } + if ( + detectedIntent === "customer_revenue_and_payments" && + isOpenScopeValueFlowWithoutSubject(entryPoint) + ) { + return true; + } return !isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index ecf2ed8..dbd2435 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -436,6 +436,20 @@ function hasOrganizationScopeSignalUtf8(text: string): boolean { ); } +function extractOrganizationScopeFromRawText(value: unknown): string | null { + const text = toNonEmptyString(value); + if (!text) { + return null; + } + const match = text.match( + /(?:^|[\s,;:])(?:\u043f\u043e|for|in|within)?\s*((?:\u041e\u041e\u041e|\u0418\u041f|\u0410\u041e|\u041f\u0410\u041e|\u0417\u0410\u041e|LLC|Inc|LTD|Corp)\s+[^\n,.;:!?]+)/u + ); + if (!match?.[1]) { + return null; + } + return toNonEmptyString(match[1]); +} + function hasMonthlyAggregationSignal(text: string): boolean { return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test( text @@ -803,18 +817,35 @@ export function buildAssistantMcpDiscoveryTurnInput( const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope); 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 organizationClarificationFollowupApplicable = Boolean( + followupSeed.domain === "counterparty_value" && + !followupSeed.counterparty && + rawOrganizationMentionSignal && + (rawOrganizationScope || followupSeed.organization) && + !rawLifecycleSignal && + !rawValueFlowSignal && + !rawMetadataSignal + ); const rawOpenScopeValueFlowOrganizationSignal = Boolean( rawValueFlowSignal && !rawBidirectionalValueFlowSignal && - hasOrganizationScopeSignalUtf8(rawText) && - (predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope) + explicitOrganizationScopeSignal ); const predecomposeOrganizationMirrorsCounterparty = sameScopedName( predecomposeEntities.counterparty, predecomposeEntities.organization ); const organizationMirrorsPredecomposeCounterparty = Boolean( - (rawBidirectionalValueFlowSignal || hasValueRankingSignal(rawText) || rawOpenScopeValueFlowOrganizationSignal) && + (rawBidirectionalValueFlowSignal || + hasValueRankingSignal(rawText) || + rawOpenScopeValueFlowOrganizationSignal || + explicitOrganizationScopeSignal) && (sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) || predecomposeOrganizationMirrorsCounterparty) ); @@ -826,7 +857,11 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.domain && !rawLifecycleSignal && !rawValueFlowSignal && - (monthlyAggregationSignal || explicitDateScopeLiteralDetected || predecomposeDateScope) + (monthlyAggregationSignal || + explicitDateScopeLiteralDetected || + predecomposeDateScope || + explicitOrganizationScopeSignal || + organizationClarificationFollowupApplicable) ); const metadataFollowupSeedApplicable = Boolean( followupSeed.domain === "metadata" && @@ -1114,6 +1149,7 @@ export function buildAssistantMcpDiscoveryTurnInput( bidirectionalValueFlowSignal || hasValueRankingSignal(rawText) || rawOpenScopeValueFlowOrganizationSignal || + explicitOrganizationScopeSignal || followupSeed.organization ); if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) { @@ -1122,7 +1158,10 @@ export function buildAssistantMcpDiscoveryTurnInput( } const explicitOrganizationScope = valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty - ? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization + ? rawOrganizationScope ?? + predecomposeEntities.organization ?? + assistantTurnMeaningOrganizationScope ?? + followupSeed.organization : null; if (valueFlowOrganizationStaysScope && explicitOrganizationScope) { for (let index = entityCandidates.length - 1; index >= 0; index -= 1) { @@ -1258,6 +1297,7 @@ export function buildAssistantMcpDiscoveryTurnInput( groundedValueFlowFollowupApplicable, forceDiscoveryOverExplicitIntent: Boolean(entityResolutionClarificationCandidate) || + organizationClarificationFollowupApplicable || metadataAmbiguityLaneClarificationApplicable || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || @@ -1308,6 +1348,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (entityResolutionClarificationCandidate) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarification_candidate_selected"); } + if (organizationClarificationFollowupApplicable) { + pushReason(reasonCodes, "mcp_discovery_organization_clarification_followup_from_followup_context"); + } if (payoutSignal) { pushReason(reasonCodes, "mcp_discovery_payout_signal_detected"); } diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts index 3992750..10e44d5 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts @@ -137,4 +137,27 @@ describe("assistant MCP discovery data need graph", () => { ]); expect(result.reason_codes).toContain("data_need_graph_open_scope_total_without_subject"); }); + it("treats a generic incoming total as an understood open-scope ask that still needs organization", () => { + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "counterparty value-flow evidence", + rawUtterance: "сколько входящих денег за 2020 год?", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020" + } + }); + + expect(result.business_fact_family).toBe("value_flow"); + expect(result.subject_candidates).toEqual([]); + expect(result.clarification_gaps).toEqual(["organization"]); + 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"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index 5b58d04..fb1e556 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -474,4 +474,42 @@ describe("assistant MCP discovery planner", () => { 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"); }); + it("keeps generic one-sided open totals in organization clarification instead of forcing entity resolution", () => { + 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: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "clarification_required", + clarification_gaps: ["organization"], + 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_open_scope_total_needs_organization" + ] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020" + } + }); + + expect(result.planner_status).toBe("needs_clarification"); + 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(["period", "organization", "aggregate_axis", "amount", "coverage_target"]); + expect(result.catalog_review.review_status).toBe("needs_more_axes"); + expect(result.catalog_review.missing_axes_by_primitive.query_movements).toContainEqual(["organization"]); + 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"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index 565158f..b06cb74 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -161,6 +161,52 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_aligned_factual_address_reply"); }); + it("does not treat an open-scope value-flow total as aligned with exact top-counterparty carryover", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "\u0421\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u043a\u043b\u0438\u0435\u043d\u0442: \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + dialogContinuationContract: { + target_intent: "customer_revenue_and_payments" + }, + truth_gate_contract_status: "full_confirmed", + assistant_truth_answer_policy_v1: { + truth_gate: { + coverage_status: "full", + grounding_status: "grounded", + source_truth_gate_status: "full_confirmed" + } + }, + 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: [], + reason_codes: ["data_need_graph_open_scope_total_without_subject"] + }, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + explicit_date_scope: "2020" + } + } + }) + } + }); + + 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("keeps factual address follow-up replies when they already match the continuation target intent", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "ИП Калинин Н.М. | сумма: 216600 | операций: 2", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index 4b2f3d1..51a1906 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -316,4 +316,42 @@ describe("assistant MCP discovery runtime bridge", () => { expect(result.answer_draft.confirmed_lines.join("\n")).toContain("5 000"); expect(result.answer_draft.confirmed_lines.join("\n")).not.toContain("контрагенту"); }); + it("keeps generic one-sided open totals in organization clarification without asking for a counterparty", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + 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: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "clarification_required", + clarification_gaps: ["organization"], + 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_open_scope_total_needs_organization" + ] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020" + }, + deps: buildDeps([]) + }); + + expect(result.bridge_status).toBe("needs_clarification"); + expect(result.requires_user_clarification).toBe(true); + expect(result.planner.selected_chain_id).toBe("value_flow"); + expect(result.answer_draft.next_step_line).toContain("\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e"); + expect(result.answer_draft.next_step_line).not.toContain( + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430" + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts index f2f78ee..c4a3197 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeEntryPoint.test.ts @@ -408,4 +408,104 @@ describe("assistant MCP discovery runtime entry point", () => { expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_supplier_payout_query_movements_v1"); expect(result.bridge?.answer_draft.confirmed_lines.join("\n")).toContain("исход"); }); + it("keeps a generic incoming total in organization clarification instead of asking for counterparty", async () => { + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "сколько входящих денег за 2020 год?", + deps: buildDeps([]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020" + }); + expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.turn_input.data_need_graph?.clarification_gaps).toEqual(["organization"]); + expect(result.bridge?.planner.selected_chain_id).toBe("value_flow"); + expect(result.bridge?.bridge_status).toBe("needs_clarification"); + expect(result.bridge?.answer_draft.next_step_line).toContain("\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e"); + expect(result.bridge?.answer_draft.next_step_line).not.toContain( + "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430" + ); + }); + + it("resumes a generic incoming total after organization clarification without reintroducing a counterparty", async () => { + const orgName = "ООО Альтернатива Плюс"; + const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({ + userMessage: "по ООО Альтернатива Плюс", + predecomposeContract: { + entities: { organization: orgName } + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + previous_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + }, + deps: buildDeps([ + { Period: "2020-01-15T00:00:00", Amount: 2500, Counterparty: "Клиент-А" }, + { Period: "2020-06-20T00:00:00", Amount: 1000, Counterparty: "Клиент-Б" } + ]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.source_signal).toBe("followup_context"); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: orgName, + explicit_date_scope: "2020" + }); + expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.bridge?.planner.selected_chain_id).toBe("value_flow"); + expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_value_flow_query_movements_v1"); + expect(result.bridge?.pilot.derived_value_flow).toMatchObject({ + counterparty: null, + period_scope: "2020", + total_amount: 3500 + }); + }); + it("overrides a supported exact intent when organization-only follow-up resolves an open-scope total", 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({ + userMessage: "\u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "turnover", + explicit_intent_candidate: "customer_revenue_and_payments" + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + previous_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + }, + deps: buildDeps([ + { Period: "2020-01-15T00:00:00", Amount: 2500, Counterparty: "\u041a\u043b\u0438\u0435\u043d\u0442-\u0410" }, + { Period: "2020-06-20T00:00:00", Amount: 1000, Counterparty: "\u041a\u043b\u0438\u0435\u043d\u0442-\u0411" } + ]) + }); + + expect(result.entry_status).toBe("bridge_executed"); + expect(result.discovery_attempted).toBe(true); + expect(result.turn_input.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_organization_scope: orgName, + explicit_date_scope: "2020" + }); + expect(result.turn_input.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.bridge?.planner.selected_chain_id).toBe("value_flow"); + expect(result.bridge?.pilot.pilot_scope).toBe("counterparty_value_flow_query_movements_v1"); + expect(result.bridge?.pilot.derived_value_flow).toMatchObject({ + counterparty: null, + period_scope: "2020", + total_amount: 3500 + }); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index c9e3fad..7583012 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1422,4 +1422,93 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); expect(result.data_need_graph?.subject_candidates).toEqual([]); }); + it("treats a generic incoming total as an open-scope value ask that needs organization rather than a counterparty", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "сколько входящих денег за 2020 год?" + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.data_need_graph?.subject_candidates).toEqual([]); + expect(result.data_need_graph?.clarification_gaps).toEqual(["organization"]); + 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({ + userMessage: "по ООО Альтернатива Плюс", + predecomposeContract: { + entities: { organization: orgName } + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + 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", + 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.reason_codes).toContain("mcp_discovery_seeded_from_followup_context"); + 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({ + userMessage: "\u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "turnover", + explicit_intent_candidate: "customer_revenue_and_payments" + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + 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.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + 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.reason_codes).toContain( + "mcp_discovery_organization_clarification_followup_from_followup_context" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); });