diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 2e5a029..84a278e 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1694,7 +1694,8 @@ function hasNomenclatureMarginRankingSignal(text) { const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized); const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized); const hasCalculationCue = /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(normalized); - return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue); + const hasInspectionCue = /(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test(normalized); + return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue); } function hasVatPeriodInspectionBridgeSignal(text) { const normalized = String(text ?? "").trim().toLowerCase(); diff --git a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js index 260c369..7ec3063 100644 --- a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js +++ b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js @@ -33,7 +33,8 @@ function hasInventoryMarginRankingSignal(text) { const hasMarginCue = /(?:прибыл|марж|рентаб|наценк|себестоим|выручк|profit|margin|profitability|gross\s+spread|cogs)/iu.test(normalized); const hasRankingCue = /(?:высок|низк|топ|сам(?:ая|ый|ое|ые|ой|ого|ому|ым|ых|ую)|больш|меньш|ранж|рейтинг|max|min|high|low|top|rank|best|worst)/iu.test(normalized); const hasCalculationCue = /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test(normalized); - return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue); + const hasInspectionCue = /(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test(normalized); + return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue); } function hasInventoryOnHandSignal(text) { const hasColloquialStockSnapshotCue = /(?:что|С‡[еёо])\s+(?:Сѓ\s+нас\s+)?РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)(?=$|[\s,.;:!?])/iu.test(text); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index fd266bc..b68c85f 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -703,6 +703,7 @@ function businessOverviewOrganizationScopeLabel(value) { } const comparable = compactComparable(text); if (/^(?:с|без)\s+разбивк/.test(comparable) || + /(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test(comparable) || /\b(?:входящ|исходящ|нетто|топ|контрагент|платеж|поступлен)\b/.test(comparable)) { return null; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 1c58168..c1904bd 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -79,6 +79,10 @@ function isReferentialOrganizationPlaceholder(value) { if (isNegativeCounterpartyScopePlaceholder(value)) { return true; } + const text = compactLower((0, addressTextRepair_1.repairAddressMojibakeText)(value)); + if (/(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test(text)) { + return true; + } return new Set([ "эта организация", "этой организации", @@ -925,7 +929,7 @@ function normalizeLooseOrganizationAlias(value) { if (!text) { return null; } - if (isNegativeCounterpartyScopePlaceholder(text)) { + if (isReferentialOrganizationPlaceholder(text)) { return null; } const normalized = text diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 1d1da4c..6be0468 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2200,7 +2200,11 @@ function hasNomenclatureMarginRankingSignal(text: string): boolean { /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test( normalized ); - return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue); + const hasInspectionCue = + /(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test( + normalized + ); + return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue); } function hasVatPeriodInspectionBridgeSignal(text: string): boolean { diff --git a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts index f8269e5..e2aceb4 100644 --- a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts +++ b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts @@ -57,7 +57,11 @@ function hasInventoryMarginRankingSignal(text: string): boolean { /(?:посчита\p{L}*|рассчита\p{L}*|расч[её]т\p{L}*|расчита\p{L}*|понять|calculate|compute)/iu.test( normalized ); - return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue); + const hasInspectionCue = + /(?:провер\p{L}*|посмотр\p{L}*|разобра\p{L}*|какую|какие|что\s+можно|можно\s+(?:провер|посчит|рассчит|понять)|check|inspect|verify|estimate)/iu.test( + normalized + ); + return hasNomenclatureCue && hasMarginCue && (hasRankingCue || hasCalculationCue || hasInspectionCue); } function hasInventoryOnHandSignal(text: string): boolean { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 42d20fa..17cef2b 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -838,6 +838,9 @@ function businessOverviewOrganizationScopeLabel(value: unknown): string | null { const comparable = compactComparable(text); if ( /^(?:с|без)\s+разбивк/.test(comparable) || + /(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test( + comparable + ) || /\b(?:входящ|исходящ|нетто|топ|контрагент|платеж|поступлен)\b/.test(comparable) ) { return null; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 3277535..3dc4e36 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -126,6 +126,14 @@ function isReferentialOrganizationPlaceholder(value: string | null): boolean { if (isNegativeCounterpartyScopePlaceholder(value)) { return true; } + const text = compactLower(repairAddressMojibakeText(value)); + if ( + /(?:без\s+(?:предположен\p{L}*|допущен\p{L}*|гадан\p{L}*|фантази\p{L}*|догад\p{L}*)|without\s+(?:guesses|assumptions|guessing))/iu.test( + text + ) + ) { + return true; + } return new Set([ "эта организация", "этой организации", @@ -1326,7 +1334,7 @@ function normalizeLooseOrganizationAlias(value: string | null): string | null { if (!text) { return null; } - if (isNegativeCounterpartyScopePlaceholder(text)) { + if (isReferentialOrganizationPlaceholder(text)) { return null; } const normalized = text diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts index 95239b5..6d50acc 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -132,6 +132,16 @@ describe("addressIntentResolver regression bridges", () => { expect(result.reasons).toContain("unicode_nomenclature_margin_ranking_bridge_signal_detected"); }); + it("routes agent margin-inspection follow-up to inventory margin instead of unknown", () => { + const result = resolveAddressIntent( + "\u0442\u043e\u0433\u0434\u0430 \u043a\u0430\u043a\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438\u043b\u0438 \u043c\u0430\u0440\u0436\u0443 \u043c\u043e\u0436\u043d\u043e \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043f\u043e \u0442\u043e\u0432\u0430\u0440\u0430\u043c \u0437\u0430 2020?" + ); + + expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature"); + expect(result.intent).not.toBe("unknown"); + expect(result.reasons).toContain("unicode_nomenclature_margin_ranking_bridge_signal_detected"); + }); + it("detects bare historical inventory root with explicit month-year", () => { const result = resolveAddressIntent("остатки РЅР° март 2016"); diff --git a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts index 795486f..ff926fa 100644 --- a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts @@ -44,6 +44,15 @@ describe("addressInventoryIntentSignals", () => { expect(result.intent).toBe("inventory_margin_ranking_for_nomenclature"); }); + it("classifies margin inspection wording from the phase106 replay as inventory margin", () => { + const result = resolveInventoryAddressIntent( + "\u0442\u043e\u0433\u0434\u0430 \u043a\u0430\u043a\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438\u043b\u0438 \u043c\u0430\u0440\u0436\u0443 \u043c\u043e\u0436\u043d\u043e \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043f\u043e \u0442\u043e\u0432\u0430\u0440\u0430\u043c \u0437\u0430 2020?" + ); + + expect(result?.intent).toBe("inventory_margin_ranking_for_nomenclature"); + expect(result?.reasons).toContain("inventory_margin_ranking_signal_detected"); + }); + it("classifies selected-object purchase provenance wording through the extracted inventory owner", () => { const result = resolveInventoryAddressIntent("selected object supplier provenance"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index dca30d7..a47672d 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -1132,6 +1132,55 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("generic advice"); }); + it("does not render negative next-check instruction as organization scope", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + raw_message: + "\u0447\u0442\u043e \u0434\u0430\u043b\u044c\u0448\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043d\u044f\u0442\u044c \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0431\u0438\u0437\u043d\u0435\u0441\u0430, \u0431\u0435\u0437 \u0433\u0430\u0434\u0430\u043d\u0438\u0439?", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + explicit_organization_scope: "\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + incoming_customer_revenue: { total_amount_human_ru: "47 628 853,03 руб." }, + outgoing_supplier_payout: { total_amount_human_ru: "43 763 351,53 руб." }, + net_amount_human_ru: "3 865 501,50 руб.", + net_direction: "net_incoming" + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "generic advice should not leak", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("\u0412 \u043f\u0435\u0440\u0432\u0443\u044e \u043e\u0447\u0435\u0440\u0435\u0434\u044c \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438"); + expect(candidate.reply_text).not.toContain("\u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438 \u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439"); + }); + it("keeps counterparty leaders for money followups instead of compact cashflow", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 670070f..16e3585 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -4112,4 +4112,39 @@ describe("assistant MCP discovery turn input adapter", () => { ); expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty"); }); + + it("uses retained organization scope instead of negative next-check instruction text", () => { + 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: + "\u0447\u0442\u043e \u0434\u0430\u043b\u044c\u0448\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043d\u044f\u0442\u044c \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0431\u0438\u0437\u043d\u0435\u0441\u0430, \u0431\u0435\u0437 \u0433\u0430\u0434\u0430\u043d\u0438\u0439?", + assistantTurnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }, + predecomposeContract: { + entities: { + counterparty: "\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439" + } + }, + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { organization: orgName }, + root_filters: { organization: orgName }, + previous_anchor_type: "organization", + previous_anchor_value: orgName, + previous_intent: "business_overview", + root_intent: "business_overview" + } + }); + + expect(result.turn_meaning_ref?.explicit_organization_scope).toBe(orgName); + expect(result.turn_meaning_ref?.explicit_organization_scope).not.toBe( + "\u0431\u0435\u0437 \u043f\u0440\u0435\u0434\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0439" + ); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + }); });