diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js index 0925f16..397c177 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -20,6 +20,16 @@ function groupRowsByMarker(rows) { function formatOptionalDate(value, formatDateRu) { return value ? formatDateRu(value) : "дата не указана"; } +function findFocusedCounterpartyValuePoint(profileRows, counterpartyHint, deps) { + if (!counterpartyHint) { + return null; + } + const matched = profileRows.find((item) => deps.counterpartyLookupMatches(item.name, counterpartyHint)); + if (matched) { + return matched; + } + return profileRows.length === 1 ? profileRows[0] : null; +} function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { if (intent === "counterparty_population_and_roles") { const rowsByMarker = groupRowsByMarker(rows); @@ -459,6 +469,31 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { lines.push("По выбранному окну данных платежные строки не найдены."); return (0, replyContracts_1.buildFactualSummaryReply)(lines); } + const focusedCounterparty = focus === "top_by_total" || focus === "total_flow" + ? findFocusedCounterpartyValuePoint(profileRows, options.counterpartyHint, deps) + : null; + if (focusedCounterparty) { + const periodLabel = options.periodFrom && options.periodTo + ? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}` + : "за доступное время"; + const directAnswerLine = isSupplier + ? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.` + : `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`; + const summaryLines = [ + directAnswerLine, + "", + "Подтверждение:", + `- Контрагент в выборке: ${focusedCounterparty.name}.`, + `- Операций: ${focusedCounterparty.ops}.` + ]; + if (focusedCounterparty.lastPeriod) { + summaryLines.push(`- Последняя подтвержденная операция: ${deps.formatDateRu(focusedCounterparty.lastPeriod)}.`); + } + if (profileRows.length > 1) { + summaryLines.push(`- Всего контрагентов в срезе: ${profileRows.length}.`); + } + return (0, replyContracts_1.buildFactualSummaryReply)(summaryLines); + } if (focus === "total_flow") { const periodLine = options.periodFrom && options.periodTo ? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.` diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts index e000452..ea0b8f0 100644 --- a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -105,6 +105,21 @@ function formatOptionalDate(value: string | null, formatDateRu: (isoDate: string return value ? formatDateRu(value) : "дата не указана"; } +function findFocusedCounterpartyValuePoint( + profileRows: CounterpartyValuePoint[], + counterpartyHint: string | null | undefined, + deps: Pick +): CounterpartyValuePoint | null { + if (!counterpartyHint) { + return null; + } + const matched = profileRows.find((item) => deps.counterpartyLookupMatches(item.name, counterpartyHint)); + if (matched) { + return matched; + } + return profileRows.length === 1 ? profileRows[0] : null; +} + export function composeCounterpartyAnalyticsReply( intent: AddressIntent, rows: ComposeStageRow[], @@ -605,6 +620,34 @@ export function composeCounterpartyAnalyticsReply( return buildFactualSummaryReply(lines); } + const focusedCounterparty = + focus === "top_by_total" || focus === "total_flow" + ? findFocusedCounterpartyValuePoint(profileRows, options.counterpartyHint, deps) + : null; + if (focusedCounterparty) { + const periodLabel = + options.periodFrom && options.periodTo + ? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}` + : "за доступное время"; + const directAnswerLine = isSupplier + ? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.` + : `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`; + const summaryLines = [ + directAnswerLine, + "", + "Подтверждение:", + `- Контрагент в выборке: ${focusedCounterparty.name}.`, + `- Операций: ${focusedCounterparty.ops}.` + ]; + if (focusedCounterparty.lastPeriod) { + summaryLines.push(`- Последняя подтвержденная операция: ${deps.formatDateRu(focusedCounterparty.lastPeriod)}.`); + } + if (profileRows.length > 1) { + summaryLines.push(`- Всего контрагентов в срезе: ${profileRows.length}.`); + } + return buildFactualSummaryReply(summaryLines); + } + if (focus === "total_flow") { const periodLine = options.periodFrom && options.periodTo diff --git a/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts index b56e9d6..a7d700d 100644 --- a/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts +++ b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts @@ -79,6 +79,41 @@ describe("counterparty analytics reply builders", () => { expect(reply.text).not.toContain("max single"); }); + it("answers specific counterparty turnover directly instead of ranking", () => { + const reply = composeFactualReply( + "customer_revenue_and_payments", + [ + { + period: "2024-02-01T00:00:00Z", + registrator: "Поступление 1", + account_dt: "", + account_kt: "", + amount: 1000, + analytics: ["СВК", "Договор СВК-1"] + }, + { + period: "2024-02-15T00:00:00Z", + registrator: "Поступление 2", + account_dt: "", + account_kt: "", + amount: 2500, + analytics: ["СВК", "Договор СВК-1"] + } + ], + { + userMessage: "какой оборот был свк", + counterpartyHint: "свк" + } + ); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text).toContain("Оборот по СВК за доступное время: 3.500,00"); + expect(reply.text).toContain("по 2 подтвержденным входящим операциям"); + expect(reply.text).toContain("Это денежный поток от клиента, а не чистая прибыль"); + expect(reply.text).not.toContain("Самый доходный клиент"); + expect(reply.text).not.toContain("Топ-"); + }); + it("explains organization activity age as 1C activity rather than legal age", () => { const reply = composeFactualReply( "counterparty_activity_lifecycle", @@ -132,7 +167,8 @@ describe("counterparty analytics reply builders", () => { ]); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("Профиль договорной базы собран по справочнику и подтвержденным операциям."); + expect(reply.text).toContain("Коротко: Использованных договоров: 148 из 520 (28.5%)."); + expect(reply.text).toContain("Что видно по договорной базе: 2 строк в подтвержденной выборке."); expect(reply.text).toContain("Использованных договоров с подтвержденной связью с операциями: 148."); }); });