From d1536ed84b55a6494940b8f4c83d51224e248109 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 25 May 2026 23:03:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BE=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B5=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D1=83=D1=87=D0=B5=D1=82=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B0=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assistantMcpDiscoveryResponseCandidate.js | 32 +++++++++ .../assistantMcpDiscoveryResponseCandidate.ts | 45 ++++++++++++ ...stantMcpDiscoveryResponseCandidate.test.ts | 71 +++++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index f1d40ee..8a33af0 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -858,6 +858,8 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph); const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph); const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? toNonEmptyString(turnMeaning?.effective_message); + const earningsAmountRequested = /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0434\u043e\u0445\u043e\u0434\p{L}*)/iu.test(normalizeQuestionText(rawMessage ?? "")); + const accountingFinancialResultForEarnings = toRecordObject(overview.accounting_financial_result); const rawMessageComparable = compactComparable(rawMessage); const organizationScopeComparable = compactComparable(organizationScope); const plainOrganizationClarificationSelection = Boolean(separateSubject && @@ -868,6 +870,36 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { rawMessageComparable.includes(organizationScopeComparable) && rawMessage.length <= 90 && !/(?:сравн|подтвержд|деньг|сколько|что\s+|покаж|дай|вывод|нельзя|клиент|поставщик|\?)/iu.test(rawMessage)); + if (compactCashflowRequested && + !rankingNeed && + !profitMarginBoundary && + !cashflowPolarityRequested && + earningsAmountRequested && + accountingFinancialResultForEarnings) { + const direction = toNonEmptyString(accountingFinancialResultForEarnings.final_result_direction); + const amount = moneyText(accountingFinancialResultForEarnings.final_result_amount_human_ru); + const periodScope = toNonEmptyString(accountingFinancialResultForEarnings.period_scope) ?? period; + const marginPct = typeof accountingFinancialResultForEarnings.net_margin_to_revenue_pct === "number" && + Number.isFinite(accountingFinancialResultForEarnings.net_margin_to_revenue_pct) + ? `${accountingFinancialResultForEarnings.net_margin_to_revenue_pct}%` + : null; + const directionText = direction === "profit" + ? "\u0443\u0447\u0435\u0442\u043d\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c" + : direction === "loss" + ? "\u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0443\u0431\u044b\u0442\u043e\u043a" + : "\u043d\u0443\u043b\u0435\u0432\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442"; + const amountText = amount + ? direction === "loss" + ? `\u043c\u0438\u043d\u0443\u0441 ${amount}` + : amount + : "\u0441\u0443\u043c\u043c\u0430 \u043d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u0430"; + lines.push(`${organizationPrefix}\u0437\u0430 ${periodScope} \u043f\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e \u0441\u0447\u0435\u0442\u043e\u0432 90/91/99 \u0432 1\u0421 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0430 ${directionText}: ${amountText}${marginPct ? `; \u043c\u0430\u0440\u0436\u0430 \u043a \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u043e\u0439 \u0432\u044b\u0440\u0443\u0447\u043a\u0435 ${marginPct}` : ""}.`); + if (incomingAmount || outgoingAmount || netAmount) { + lines.push(`\u0414\u043b\u044f \u0441\u0432\u0435\u0440\u043a\u0438 \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c: \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431."}.`); + } + lines.push("\u0412\u0430\u0436\u043d\u043e: \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435 \u0434\u0435\u043d\u0435\u0433 - \u0440\u0430\u0437\u043d\u044b\u0435 \u043c\u0435\u0442\u0440\u0438\u043a\u0438; \u043d\u0435 \u0441\u043c\u0435\u0448\u0438\u0432\u0430\u044e \u0438\u0445 \u0432 \u043e\u0434\u043d\u0443 \"\u043f\u0440\u0438\u0431\u044b\u043b\u044c\"."); + return joinBusinessReplyLines(lines); + } if (compactCashflowRequested && !rankingNeed && (incomingAmount || outgoingAmount || netAmount)) { const netDisplay = sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431."; const signedNetDisplay = cashflowPolarityRequested && netDisplay && !String(netDisplay).trim().startsWith("-") diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 040b6cb..0e4c6db 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -1032,6 +1032,10 @@ function buildCompactBusinessOverviewReply( const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph); const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph); const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? toNonEmptyString(turnMeaning?.effective_message); + const earningsAmountRequested = /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0434\u043e\u0445\u043e\u0434\p{L}*)/iu.test( + normalizeQuestionText(rawMessage ?? "") + ); + const accountingFinancialResultForEarnings = toRecordObject(overview.accounting_financial_result); const rawMessageComparable = compactComparable(rawMessage); const organizationScopeComparable = compactComparable(organizationScope); const plainOrganizationClarificationSelection = Boolean( @@ -1045,6 +1049,47 @@ function buildCompactBusinessOverviewReply( !/(?:сравн|подтвержд|деньг|сколько|что\s+|покаж|дай|вывод|нельзя|клиент|поставщик|\?)/iu.test(rawMessage) ); + if ( + compactCashflowRequested && + !rankingNeed && + !profitMarginBoundary && + !cashflowPolarityRequested && + earningsAmountRequested && + accountingFinancialResultForEarnings + ) { + const direction = toNonEmptyString(accountingFinancialResultForEarnings.final_result_direction); + const amount = moneyText(accountingFinancialResultForEarnings.final_result_amount_human_ru); + const periodScope = toNonEmptyString(accountingFinancialResultForEarnings.period_scope) ?? period; + const marginPct = + typeof accountingFinancialResultForEarnings.net_margin_to_revenue_pct === "number" && + Number.isFinite(accountingFinancialResultForEarnings.net_margin_to_revenue_pct) + ? `${accountingFinancialResultForEarnings.net_margin_to_revenue_pct}%` + : null; + const directionText = + direction === "profit" + ? "\u0443\u0447\u0435\u0442\u043d\u0430\u044f \u043f\u0440\u0438\u0431\u044b\u043b\u044c" + : direction === "loss" + ? "\u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0443\u0431\u044b\u0442\u043e\u043a" + : "\u043d\u0443\u043b\u0435\u0432\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442"; + const amountText = amount + ? direction === "loss" + ? `\u043c\u0438\u043d\u0443\u0441 ${amount}` + : amount + : "\u0441\u0443\u043c\u043c\u0430 \u043d\u0435 \u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u0430"; + lines.push( + `${organizationPrefix}\u0437\u0430 ${periodScope} \u043f\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e \u0441\u0447\u0435\u0442\u043e\u0432 90/91/99 \u0432 1\u0421 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0430 ${directionText}: ${amountText}${marginPct ? `; \u043c\u0430\u0440\u0436\u0430 \u043a \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u043e\u0439 \u0432\u044b\u0440\u0443\u0447\u043a\u0435 ${marginPct}` : ""}.` + ); + if (incomingAmount || outgoingAmount || netAmount) { + lines.push( + `\u0414\u043b\u044f \u0441\u0432\u0435\u0440\u043a\u0438 \u043f\u043e \u0434\u0435\u043d\u044c\u0433\u0430\u043c: \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438 ${incomingAmount ?? "0 \u0440\u0443\u0431."}; \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438/\u0441\u043f\u0438\u0441\u0430\u043b\u0438 ${outgoingAmount ?? "0 \u0440\u0443\u0431."}; \u0434\u0435\u043d\u0435\u0436\u043d\u043e\u0435 \u043d\u0435\u0442\u0442\u043e ${sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431."}.` + ); + } + lines.push( + "\u0412\u0430\u0436\u043d\u043e: \u0443\u0447\u0435\u0442\u043d\u044b\u0439 \u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 \u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435 \u0434\u0435\u043d\u0435\u0433 - \u0440\u0430\u0437\u043d\u044b\u0435 \u043c\u0435\u0442\u0440\u0438\u043a\u0438; \u043d\u0435 \u0441\u043c\u0435\u0448\u0438\u0432\u0430\u044e \u0438\u0445 \u0432 \u043e\u0434\u043d\u0443 \"\u043f\u0440\u0438\u0431\u044b\u043b\u044c\"." + ); + return joinBusinessReplyLines(lines); + } + if (compactCashflowRequested && !rankingNeed && (incomingAmount || outgoingAmount || netAmount)) { const netDisplay = sentenceAmount(netAmount) ?? netAmount ?? "0 \u0440\u0443\u0431."; const signedNetDisplay = diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 533bb52..ec0e4b1 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -705,6 +705,77 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("wide overview"); }); + it("leads earnings wording with accounting result when explicit-period proof is available", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + raw_message: "а за 2017 мы скок заработали?", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2017" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: [ + "data_need_graph_family_business_overview", + "data_need_graph_business_overview_direct_money_answer" + ] + } + }, + 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: { + period_scope: "2017", + incoming_customer_revenue: { + total_amount_human_ru: "16 932 063,96 руб." + }, + outgoing_supplier_payout: { + total_amount_human_ru: "15 617 432,52 руб." + }, + net_amount_human_ru: "1 314 631,44 руб.", + net_direction: "net_incoming", + top_customers: [], + top_suppliers: [], + accounting_financial_result: { + final_result_direction: "profit", + final_result_amount_human_ru: "116 938,23 руб.", + net_margin_to_revenue_pct: 1.03, + period_scope: "2017" + } + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "wide overview should not leak", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("учетная прибыль"); + expect(candidate.reply_text).toContain("116 938,23"); + expect(candidate.reply_text).toContain("маржа"); + expect(candidate.reply_text).toContain("1.03%"); + expect(candidate.reply_text).toContain("денежное нетто"); + expect(candidate.reply_text).toContain("1 314 631,44"); + expect(candidate.reply_text).toContain("разные метрики"); + expect(candidate.reply_text).not.toContain("wide overview"); + }); + it("uses compact cashflow output for one-line no-breakdown wording", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({