diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index fefd8ec..dcb968d 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -938,6 +938,12 @@ function nextStepFor(mode, pilot) { } return "Следующий шаг - связать inventory route-template с exact inventory runtime и затем проверить live-прогоном."; } + if (mode === "checked_sources_only" && hasExecutedZeroValueFlowRows(pilot)) { + const flowLabel = pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1" + ? "исходящие платежи/списания" + : "входящие денежные поступления"; + return `Можно следующим шагом проверить ${flowLabel} по этому контрагенту в соседних периодах, по договорам или документам, либо собрать двусторонний денежный поток, чтобы отделить отсутствие строк в текущем срезе от операций вне проверенного окна.`; + } if (mode === "confirmed_with_bounded_inference" && isBusinessOverviewPilot(pilot)) { return pilot.derived_business_overview ? businessOverviewNextStepLine(pilot.derived_business_overview) diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index d85706d..1c58168 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -882,6 +882,10 @@ function hasBidirectionalValueFlowSignal(text) { if (/(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0431\u0430\u043b\u0430\u043d\u0441\s+(?:\u043f\u043b\u0430\u0442|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436)|\u0432\u0437\u0430\u0438\u043c\u043e\u0440\u0430\u0441\u0447[\u0435\u0451]\u0442|\u043f\u043e\u043b\u0443\u0447\p{L}*.*(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*|(?:\u0437\u0430)?\u043f\u043b\u0430\u0442\p{L}*.*\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\u044f\u0449.*\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449.*\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u0440\u0438\u0448\p{L}*.*\u0443\u0448\p{L}*|\u0443\u0448\p{L}*.*\u043f\u0440\u0438\u0448\p{L}*|\u043f\u043b\u044e\u0441.*\u043c\u0438\u043d\u0443\u0441|\u043c\u0438\u043d\u0443\u0441.*\u043f\u043b\u044e\u0441|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(text)) { return true; } + if (/(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test(text) && + hasPayoutSignal(text)) { + return true; + } return /(?:нетто|сальдо|баланс\s+(?:плат|денег|денеж)|взаиморасч[её]т|получил[иа]?.*(?:за)?платил|(?:за)?платил[иа]?.*получил|входящ.*исходящ|исходящ.*входящ|дебет.*кредит|кредит.*дебет|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test(text); } function hasValueRankingSignal(text) { diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index 93a9ff3..55c264a 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -184,7 +184,10 @@ function detectScopedCounterpartyEntity(text) { if (!rawEntity) { continue; } - const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim(); + const entity = rawEntity + .replace(/^["'«»]+|["'«»]+$/gu, "") + .replace(/^(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043b\u0438\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?)\s+/iu, "") + .trim(); if (entity.length >= 2 && !ignored.has(entity)) { return entity; } @@ -195,7 +198,8 @@ function detectCounterpartyBidirectionalValueFlowFamily(text) { const hasNetCue = /(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|net\s+(?:flow|cash|payment)|cash\s+net)/iu.test(text); const hasIncomingCue = /(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text); const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text); - if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) { + const hasOverallMoneyMeaningCue = /(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test(text); + if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue) || (hasOverallMoneyMeaningCue && hasOutgoingCue))) { return null; } const entity = detectScopedCounterpartyEntity(text); @@ -410,7 +414,9 @@ function createAssistantTurnMeaningPolicy(deps = {}) { : detectBroadBusinessEvaluation(joinedText); const explicitIntentCandidate = broadBusinessEvaluation?.family ? null - : supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); + : counterpartyBidirectionalValueFlow?.family + ? null + : supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); const unsupportedFamily = broadBusinessEvaluation?.family ? broadBusinessEvaluation.family : !explicitIntentCandidate && counterpartyBidirectionalValueFlow?.family diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index e6213b4..cd2cf18 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -1097,6 +1097,13 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD } return "Следующий шаг - связать inventory route-template с exact inventory runtime и затем проверить live-прогоном."; } + if (mode === "checked_sources_only" && hasExecutedZeroValueFlowRows(pilot)) { + const flowLabel = + pilot.pilot_scope === "counterparty_supplier_payout_query_movements_v1" + ? "исходящие платежи/списания" + : "входящие денежные поступления"; + return `Можно следующим шагом проверить ${flowLabel} по этому контрагенту в соседних периодах, по договорам или документам, либо собрать двусторонний денежный поток, чтобы отделить отсутствие строк в текущем срезе от операций вне проверенного окна.`; + } if (mode === "confirmed_with_bounded_inference" && isBusinessOverviewPilot(pilot)) { return pilot.derived_business_overview ? businessOverviewNextStepLine(pilot.derived_business_overview) diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 57602bf..3277535 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -1258,6 +1258,12 @@ function hasBidirectionalValueFlowSignal(text: string): boolean { ) { return true; } + if ( + /(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test(text) && + hasPayoutSignal(text) + ) { + return true; + } return /(?:нетто|сальдо|баланс\s+(?:плат|денег|денеж)|взаиморасч[её]т|получил[иа]?.*(?:за)?платил|(?:за)?платил[иа]?.*получил|входящ.*исходящ|исходящ.*входящ|дебет.*кредит|кредит.*дебет|net\s+(?:flow|cash|payment)|cash\s+net|incoming\s+and\s+outgoing|received\s+and\s+paid|paid\s+and\s+received)/iu.test( text ); diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 85be9e2..a77d997 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -202,7 +202,10 @@ function detectScopedCounterpartyEntity(text) { if (!rawEntity) { continue; } - const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim(); + const entity = rawEntity + .replace(/^["'«»]+|["'«»]+$/gu, "") + .replace(/^(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043b\u0438\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442(?:\u0443|\u0430|\u043e\u043c|\u0435)?)\s+/iu, "") + .trim(); if (entity.length >= 2 && !ignored.has(entity)) { return entity; } @@ -217,7 +220,11 @@ function detectCounterpartyBidirectionalValueFlowFamily(text) { /(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text); const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text); - if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) { + const hasOverallMoneyMeaningCue = + /(?:\u043e\u0431\u0449\p{L}*[\s\S]{0,60}\u0434\u0435\u043d\p{L}*[\s\S]{0,60}\u0441\u043c\u044b\u0441\u043b|\u0434\u0435\u043d\p{L}*[\s\S]{0,40}\u0441\u043c\u044b\u0441\u043b|overall[\s\S]{0,40}(?:cash|money)[\s\S]{0,40}(?:meaning|sense)|money[\s\S]{0,40}(?:meaning|sense))/iu.test( + text + ); + if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue) || (hasOverallMoneyMeaningCue && hasOutgoingCue))) { return null; } const entity = detectScopedCounterpartyEntity(text); @@ -553,6 +560,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { const explicitIntentCandidate = broadBusinessEvaluation?.family ? null + : counterpartyBidirectionalValueFlow?.family + ? null : supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); const unsupportedFamily = broadBusinessEvaluation?.family ? broadBusinessEvaluation.family diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index a48060e..9eb66d7 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -137,6 +137,8 @@ describe("assistant MCP discovery answer adapter", () => { expect(answerText).toContain("исходящих платежей/списаний не найдено"); expect(answerText).toContain("Группа СВК"); expect(answerText).toContain("2020"); + expect(draft.next_step_line).toContain("соседних периодах"); + expect(draft.next_step_line).toContain("двусторонний денежный поток"); }); it("turns generic document evidence into a bounded document answer draft", async () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index ae372ec..670070f 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -163,6 +163,42 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected"); }); + it("treats supplier payout plus overall money meaning as bidirectional value-flow discovery", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "А теперь поставщику Группа СВК за 2020 сколько мы ему заплатили и какой общий денежный смысл?", + assistantTurnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: [{ value: "группа свк" }], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting", + stale_replay_forbidden: true + }, + predecomposeContract: { + entities: { counterparty: "Группа СВК" }, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["группа свк"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting", + stale_replay_forbidden: true + }); + expect(result.data_need_graph).toMatchObject({ + business_fact_family: "value_flow", + action_family: "net_value_flow", + comparison_need: "incoming_vs_outgoing" + }); + expect(result.reason_codes).toContain("mcp_discovery_bidirectional_value_flow_signal_detected"); + expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected"); + }); + it("keeps explicit counterparty money-flow basis questions executable without requiring a period", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "How much money passed with SVK, incoming and outgoing, and what documents or movements prove it?", diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index 0ed6022..0d22c04 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -85,6 +85,31 @@ describe("assistantTurnMeaningPolicy", () => { expect(meaning.stale_replay_forbidden).toBe(true); }); + it("treats payout plus overall money meaning as bidirectional value-flow", () => { + const policy = buildPolicy({ + resolveAddressIntent: () => ({ intent: "supplier_payouts_profile", confidence: "high" }) + }); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0443 \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043c\u044b \u0435\u043c\u0443 \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u0449\u0438\u0439 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0439 \u0441\u043c\u044b\u0441\u043b?" + }); + + expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.asked_domain_family).toBe("counterparty_value"); + expect(meaning.asked_action_family).toBe("net_value_flow"); + expect(meaning.unsupported_but_understood_family).toBe("counterparty_bidirectional_value_flow_or_netting"); + expect(meaning.explicit_entity_candidates).toEqual([ + { + type: "counterparty", + value: "\u0433\u0440\u0443\u043f\u043f\u0430 \u0441\u0432\u043a", + source: "current_turn_loose_entity_tail" + } + ]); + expect(meaning.reason_codes).toContain("counterparty_bidirectional_value_flow_current_turn_signal"); + expect(meaning.stale_replay_forbidden).toBe(true); + }); + it("ignores temporal tail words in all-time revenue ranking questions", () => { const policy = buildPolicy({ resolveAddressIntent: (text: string) =>