From 7c77db2c8d5dda5313ac03591c18eed353326054 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sat, 9 May 2026 10:30:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B6=D0=B0=D1=82=D1=8C=20=D0=B1=D0=B8?= =?UTF-8?q?=D0=B7=D0=BD=D0=B5=D1=81-=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=B8=20=D0=B7=D0=B0=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20=D1=81=D1=83=D0=BC?= =?UTF-8?q?=D0=BC=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assistantLivingChatRuntimeAdapter.js | 1 + .../assistantMcpDiscoveryAnswerAdapter.js | 43 ++++- .../assistantMcpDiscoveryDataNeedGraph.js | 36 ++++ .../assistantMcpDiscoveryResponseCandidate.js | 160 ++++++++++++++++ .../services/assistantMemoryRecapPolicy.js | 111 ++++++++++- .../assistantLivingChatRuntimeAdapter.ts | 1 + .../assistantMcpDiscoveryAnswerAdapter.ts | 50 ++++- .../assistantMcpDiscoveryDataNeedGraph.ts | 43 +++++ .../assistantMcpDiscoveryResponseCandidate.ts | 179 ++++++++++++++++++ .../services/assistantMemoryRecapPolicy.ts | 141 +++++++++++++- .../assistantLivingChatRuntimeAdapter.test.ts | 5 + ...assistantMcpDiscoveryAnswerAdapter.test.ts | 5 + ...stantMcpDiscoveryResponseCandidate.test.ts | 143 ++++++++++++++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 59 ++++++ .../tests/assistantMemoryRecapPolicy.test.ts | 23 ++- 15 files changed, 987 insertions(+), 13 deletions(-) diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index 5020dd5..6154782 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -200,6 +200,7 @@ async function runAssistantLivingChatRuntime(input) { else if (contextualAnswerInspectionFollowup) { chatText = (0, assistantMemoryRecapPolicy_1.buildSelectedObjectAnswerInspectionReply)({ addressDebug: lastAnswerInspectionAddressDebug, + sessionItems: input.sessionItems, toNonEmptyString: input.toNonEmptyString }); livingChatSource = "deterministic_answer_inspection_contract"; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 291088a..ef3c7af 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -421,6 +421,37 @@ function businessOverviewNextStepLine(overview) { : "оставшиеся непроверенные области по выбранному контуру"; return `Следующий шаг для полного бизнес-аудита: отдельно проверить ${target}, не смешивая эти будущие проверки с уже подтвержденным обзором.`; } +function businessOverviewStrongestIncomingYear(overview) { + const years = overview.yearly_breakdown ?? []; + return [...years] + .filter((bucket) => bucket.incoming_total_amount > 0) + .sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0] ?? null; +} +function inlineBusinessOverviewAmount(value) { + return String(value ?? "") + .trim() + .replace(/\s*руб\.$/u, " рублей") + .replace(/[\s.]+$/u, ""); +} +function businessOverviewHeadlineMetricsLine(overview) { + const parts = []; + if (overview.incoming_customer_revenue.rows_with_amount > 0) { + parts.push(`входящие поступления ${inlineBusinessOverviewAmount(overview.incoming_customer_revenue.total_amount_human_ru)}`); + } + if (overview.outgoing_supplier_payout.rows_with_amount > 0) { + parts.push(`исходящие платежи/списания ${inlineBusinessOverviewAmount(overview.outgoing_supplier_payout.total_amount_human_ru)}`); + } + if (overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0) { + parts.push(`расчетное операционное нетто ${inlineBusinessOverviewAmount(overview.net_amount_human_ru)}`); + } + const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); + if (strongestIncomingYear) { + parts.push(`самый сильный год по подтвержденным входящим поступлениям ${strongestIncomingYear.year_bucket}: ${inlineBusinessOverviewAmount(strongestIncomingYear.incoming_total_amount_human_ru)}`); + } + return parts.length > 0 + ? `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат` + : null; +} function headlineFor(mode, pilot) { const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || pilot.derived_value_flow?.aggregation_axis === "month"; @@ -498,6 +529,10 @@ function headlineFor(mode, pilot) { ? "due-date просрочка" : "качество открытых расчетов"); unknownFamilies.push(businessOverviewInventoryUnknownLabel(overview)); + const metricLead = businessOverviewHeadlineMetricsLine(overview); + if (metricLead) { + return `Ограниченный бизнес-обзор по подтвержденным строкам 1С: ${metricLead}. Проверенные контуры: ${families.join(", ")}; ${unknownFamilies.join(", ")} остаются отдельными непроверенными областями.`; + } return `По данным 1С собран ограниченный бизнес-обзор: ${families.join(", ")} подтверждены найденными строками; ${unknownFamilies.join(", ")} остаются отдельными непроверенными областями.`; } if (isBusinessOverviewPilot(pilot) && mode === "checked_sources_only") { @@ -1031,6 +1066,10 @@ function derivedBusinessOverviewConfirmedLines(pilot) { if (overview.outgoing_supplier_payout.rows_with_amount > 0) { lines.push(`Исходящие платежи/списания${organization}${period}: ${overview.outgoing_supplier_payout.total_amount_human_ru} по ${overview.outgoing_supplier_payout.rows_with_amount} строкам с суммой.`); } + const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); + if (strongestIncomingYear) { + lines.push(`Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} — ${strongestIncomingYear.incoming_total_amount_human_ru} по ${strongestIncomingYear.incoming_rows_with_amount} строкам с суммой. Это не бухгалтерская прибыль.`); + } const leader = overview.top_customers[0]; if (leader) { lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); @@ -1183,9 +1222,7 @@ function businessOverviewYearlyOperatingLine(overview) { if (years.length === 0) { return null; } - const strongestIncomingYear = [...years] - .filter((bucket) => bucket.incoming_total_amount > 0) - .sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0]; + const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); const strongestNetYear = [...years] .filter((bucket) => bucket.net_amount !== 0) .sort((left, right) => right.net_amount - left.net_amount || left.year_bucket.localeCompare(right.year_bucket))[0]; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index 8bdee6a..5877889 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -107,6 +107,16 @@ function hasAllTimeScopeHint(rawUtterance) { } return /(?:\u0437\u0430\s+\u0432\u0441[\u0435\u0451]\s+\u0432\u0440\u0435\u043c\u044f|\u0437\u0430\s+\u0432\u0435\u0441\u044c\s+\u043f\u0435\u0440\u0438\u043e\u0434|\u0437\u0430\s+\u0432\u0441\u044e\s+\u0438\u0441\u0442\u043e\u0440\u0438(?:\u044e|\u0438)|\u0437\u0430\s+\u043b\u044e\u0431\u043e\u0439\s+\u043f\u0435\u0440\u0438\u043e\u0434|for\s+all\s+time|all\s+time|entire\s+period|full\s+history|any\s+period)/iu.test(rawUtterance); } +function hasBusinessOverviewDirectMoneyAnswerHint(input) { + if (input.family !== "business_overview" || !input.rawUtterance) { + return false; + } + if (input.rankingNeed) { + return true; + } + const text = input.rawUtterance; + return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|how\s+much)[\s\S]{0,120}(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u0435\u043d\p{L}*|\u043f\u043e\u043b\u0443\u0447|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*)|(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447)[\s\S]{0,120}(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u0432\u0441\u0435\u0433\u043e|\u0432\u043e\u043e\u0431\u0449\u0435|(?:19|20)\d{2}|all\s+time)|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|which|what)[\s\S]{0,80}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,80}(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,40}(?:\u0433\u043e\u0434|year)/iu.test(text); +} function timeScopeNeedFor(input) { if (input.explicitDateScope) { return "explicit_period"; @@ -183,6 +193,24 @@ function rankingNeedFromRawUtterance(value) { if (!text) { return null; } + if (/(?:\u0442\u043e\u043f[-\s]?\d*|\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)|\u0431\u043e\u043b\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u0431\u043e\u043b[\u0435\u0451]\u0435|\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c|\u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448|\u043b\u0443\u0447\u0448\u0438\u0439)/iu.test(text)) { + return "top_desc"; + } + if (/(?:\u043c\u0435\u043d\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u043c\u0435\u043d[\u044c\u0448]\u0435|\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0438\u043d\u0438\u043c\u0443\u043c|\u0445\u0443\u0434\u0448\u0438\u0439)/iu.test(text)) { + return "bottom_asc"; + } + if (/(?:\btop[-\s]?\d+\b|\btop\b|\u0442\u043e\u043f[-\s]?\d+|\u0442\u043e\u043f\b|\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)\b|\u0431\u043e\u043b\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u0431\u043e\u043b[\u0435\u0451]\u0435|\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c|\u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448|\u043b\u0443\u0447\u0448\u0438\u0439|highest|largest|most|best)/iu.test(text)) { + return "top_desc"; + } + if (/(?:\u043c\u0435\u043d\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u043c\u0435\u043d[\u044c\u0448]\u0435|\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0438\u043d\u0438\u043c\u0443\u043c|\u0445\u0443\u0434\u0448\u0438\u0439|lowest|smallest|least|worst)/iu.test(text)) { + return "bottom_asc"; + } + if (/(?:\btop[-\s]?\d+\b|\btop\b|топ[-\s]?\d+|топ\b|сам(?:ый|ая|ое|ые)\b|больше\s+всего|наибол[её]е|максимальн|максимум|крупнейш|лучший|highest|largest|most|best)/iu.test(text)) { + return "top_desc"; + } + if (/(?:меньше\s+всего|наимен[ьш]е|минимальн|минимум|худший|lowest|smallest|least|worst)/iu.test(text)) { + return "bottom_asc"; + } if (/(?:\btop[-\s]?\d+\b|\btop\b|топ[-\s]?\d+|топ\b|сам(?:ый|ая|ое|ые)\b|больше\s+всего|наибол[её]е|highest|largest|most)/iu.test(text)) { return "top_desc"; } @@ -368,6 +396,11 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const comparisonNeed = comparisonNeedFor(action); const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); + const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({ + family: businessFactFamily, + rawUtterance, + rankingNeed + }); const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action); const openScopeWithoutSubject = subjectCandidates.length === 0 && allowsOpenScopeWithoutSubject({ @@ -462,6 +495,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { if (businessFactFamily === "business_overview" && !explicitDateScope) { pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); } + if (directBusinessOverviewMoneyAnswerHint) { + pushReason(reasonCodes, "data_need_graph_business_overview_direct_money_answer"); + } if (clarificationGaps.includes("organization")) { pushReason(reasonCodes, "data_need_graph_open_scope_total_needs_organization"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index a5281ca..6b5b1ae 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -277,6 +277,162 @@ function section(title, lines) { } return `${title}\n${clean.map((line) => `- ${line}`).join("\n")}`; } +function readStringArray(value) { + return Array.isArray(value) + ? value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)) + : []; +} +function moneyText(value) { + const text = toNonEmptyString(value); + if (!text) { + return null; + } + return text.replace(/\s*руб\.$/u, " руб.").replace(/\s+/gu, " "); +} +function sentenceAmount(value) { + return value ? value.replace(/[.]+$/u, "") : null; +} +function businessOverviewPeriodText(overview) { + const period = toNonEmptyString(overview.period_scope); + return period ? `за ${period}` : "за все доступное проверенное окно"; +} +function strongestIncomingYear(overview) { + const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; + const sorted = years + .map((item) => toRecordObject(item)) + .filter((item) => { + if (!item) { + return false; + } + return Number(item.incoming_total_amount) > 0; + }) + .sort((left, right) => { + const amountDelta = Number(right.incoming_total_amount) - Number(left.incoming_total_amount); + if (amountDelta !== 0) { + return amountDelta; + } + return String(left.year_bucket ?? "").localeCompare(String(right.year_bucket ?? "")); + }); + return sorted[0] ?? null; +} +function strongestNetYear(overview) { + const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; + const sorted = years + .map((item) => toRecordObject(item)) + .filter((item) => { + if (!item) { + return false; + } + return Number(item.net_amount) !== 0; + }) + .sort((left, right) => { + const amountDelta = Number(right.net_amount) - Number(left.net_amount); + if (amountDelta !== 0) { + return amountDelta; + } + return String(left.year_bucket ?? "").localeCompare(String(right.year_bucket ?? "")); + }); + return sorted[0] ?? null; +} +function businessOverviewCoverageLimitLine(overview) { + const incoming = toRecordObject(overview.incoming_customer_revenue); + const outgoing = toRecordObject(overview.outgoing_supplier_payout); + const limited = []; + if (incoming?.coverage_limited_by_probe_limit === true) { + limited.push("входящие"); + } + if (outgoing?.coverage_limited_by_probe_limit === true) { + limited.push("исходящие"); + } + return limited.length > 0 + ? `Важно: ${limited.join(" и ")} уперлись в лимит выборки MCP, поэтому это проверенный срез найденных строк, а не гарантированно полный бухгалтерский оборот.` + : null; +} +function businessOverviewYearRowsLine(overview) { + const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; + const values = years + .map((item) => toRecordObject(item)) + .filter((item) => Boolean(item)) + .slice(0, 6) + .map((item) => { + const year = toNonEmptyString(item.year_bucket); + const incoming = moneyText(item.incoming_total_amount_human_ru); + const net = moneyText(item.net_amount_human_ru); + const direction = item.net_direction === "net_outgoing" ? "нетто в минус" : "нетто в плюс"; + return year && incoming && net ? `${year}: входящие ${incoming}, ${direction} ${net}` : null; + }) + .filter((item) => Boolean(item)); + const joined = values.join("; "); + return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null; +} +function buildCompactBusinessOverviewReply(entryPoint, draft) { + const turnInput = toRecordObject(entryPoint.turn_input); + const graph = toRecordObject(turnInput?.data_need_graph); + const bridge = toRecordObject(entryPoint.bridge); + const pilot = toRecordObject(bridge?.pilot); + const overview = toRecordObject(pilot?.derived_business_overview); + const graphReasons = readStringArray(graph?.reason_codes); + const isBusinessOverview = toNonEmptyString(graph?.business_fact_family) === "business_overview" || + toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1"; + const rankingNeed = toNonEmptyString(graph?.ranking_need); + const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer"); + if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) { + return null; + } + const incoming = toRecordObject(overview.incoming_customer_revenue); + const outgoing = toRecordObject(overview.outgoing_supplier_payout); + const incomingAmount = moneyText(incoming?.total_amount_human_ru); + const outgoingAmount = moneyText(outgoing?.total_amount_human_ru); + const netAmount = moneyText(overview.net_amount_human_ru); + const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто"; + const period = businessOverviewPeriodText(overview); + const limitLine = businessOverviewCoverageLimitLine(overview); + const lines = []; + if (rankingNeed) { + const incomingLeader = strongestIncomingYear(overview); + const netLeader = strongestNetYear(overview); + const leaderYear = toNonEmptyString(incomingLeader?.year_bucket); + const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru); + const leaderRows = Number(incomingLeader?.incoming_rows_with_amount); + if (!leaderYear || !leaderAmount) { + return null; + } + lines.push(`Коротко: самый доходный год в доступном денежном контуре 1С — ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.`); + const netYear = toNonEmptyString(netLeader?.year_bucket); + const netYearAmount = moneyText(netLeader?.net_amount_human_ru); + if (netYear && netYearAmount) { + const netLabel = netLeader?.net_direction === "net_outgoing" ? "нетто в минус" : "нетто в плюс"; + lines.push(`По расчетному операционному нетто лучший год: ${netYear}, ${netLabel} ${sentenceAmount(netYearAmount) ?? netYearAmount}.`); + } + lines.push('Метод: "доходный" здесь трактую как подтвержденные входящие поступления/выручку по найденным строкам 1С, не как чистую бухгалтерскую прибыль.'); + if (incomingAmount && outgoingAmount && netAmount) { + lines.push(`Сверка по окну: входящие ${incomingAmount}, исходящие ${outgoingAmount}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount}.`); + } + const yearRows = businessOverviewYearRowsLine(overview); + if (yearRows) { + lines.push(yearRows); + } + } + else if (incomingAmount || outgoingAmount || netAmount) { + lines.push(`Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.`); + lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); + const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null); + const customerName = toNonEmptyString(topCustomer?.axis_value); + const customerAmount = moneyText(topCustomer?.total_amount_human_ru); + if (customerName && customerAmount) { + lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`); + } + } + else { + return null; + } + if (limitLine) { + lines.push(limitLine); + } + lines.push("Для ответа именно про чистую прибыль нужно отдельно считать себестоимость, расходы и закрытие периода."); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; +} function statusFrom(entryPoint) { if (!entryPoint || entryPoint.entry_status === "skipped_not_applicable") { return "not_applicable"; @@ -320,6 +476,10 @@ function buildReplyText(entryPoint, status) { } return null; } + const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft); + if (compactBusinessOverviewReply) { + return compactBusinessOverviewReply; + } const blocks = [ toNonEmptyString(draft.headline) ? `Коротко: ${localizeLine(String(draft.headline))}` : null, section("Что подтверждено:", toStringList(draft.confirmed_lines)), diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index 9b3bddc..e28d0cc 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -455,6 +455,96 @@ function collectBusinessEvaluationEvidence(input) { moneySignalCount }; } +function readSessionAssistantText(item, toNonEmptyString) { + const record = toRecordObject(item); + if (!record) { + return null; + } + const direct = toNonEmptyString(record.text) ?? + toNonEmptyString(record.chatText) ?? + toNonEmptyString(record.assistant_message) ?? + toNonEmptyString(record.answer) ?? + toNonEmptyString(record.output); + if (direct) { + return direct; + } + const humanReadable = toRecordObject(record.human_readable); + const technicalJson = toRecordObject(record.technical_json); + return (toNonEmptyString(humanReadable?.answer) ?? + toNonEmptyString(humanReadable?.assistant_message) ?? + toNonEmptyString(technicalJson?.assistant_message)); +} +function readSessionItemTraceId(item, toNonEmptyString) { + const record = toRecordObject(item); + const debug = toRecordObject(record?.debug) ?? toRecordObject(toRecordObject(record?.technical_json)?.debug); + return toNonEmptyString(debug?.trace_id) ?? toNonEmptyString(record?.trace_id); +} +function findPriorAssistantAnswerForDebug(input) { + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + if (sessionItems.length === 0) { + return null; + } + const targetTraceId = input.toNonEmptyString(input.addressDebug?.trace_id); + const targetItemKey = normalizeRecapIdentity(input.item); + let fallbackText = null; + for (let index = sessionItems.length - 1; index >= 0; index -= 1) { + const record = toRecordObject(sessionItems[index]); + if (!record || record.role !== "assistant") { + continue; + } + const text = readSessionAssistantText(record, input.toNonEmptyString); + if (!text) { + continue; + } + const traceId = readSessionItemTraceId(record, input.toNonEmptyString); + if (targetTraceId && traceId === targetTraceId) { + return text; + } + const debug = toRecordObject(record.debug) ?? toRecordObject(toRecordObject(record.technical_json)?.debug); + const detectedIntent = String(debug?.detected_intent ?? ""); + const debugContext = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(debug, input.toNonEmptyString); + const itemMatches = targetItemKey && normalizeRecapIdentity(debugContext.item) === targetItemKey; + if (detectedIntent === input.detectedIntent && itemMatches && !fallbackText) { + fallbackText = text; + } + } + return fallbackText; +} +function cleanExtractedCounterpartyLabel(value, itemLabel) { + let text = String(value ?? "") + .replace(/\*\*/g, "") + .replace(/[«»"]/g, "") + .split("|")[0] + .split("\n")[0] + .trim(); + text = text.replace(/^[\s:.-]+/u, "").replace(/[\s,;:.]+$/u, "").trim(); + if (!text || /(?:не выделен|не найден|не определен|не подтвержден)/iu.test(text)) { + return null; + } + if (itemLabel && normalizeRecapIdentity(text) === normalizeRecapIdentity(itemLabel)) { + return null; + } + return text; +} +function extractBuyerFromSaleTraceAnswer(answerText, itemLabel) { + if (!answerText) { + return null; + } + const patterns = [ + /покупатель\s+определ[её]н\s*:\s*([^\n]+)/iu, + /отгружал[^\n:]*покупател[^\n:]*:\s*([^\n]+)/iu, + /покупател[^\n:]*:\s*([^\n]+)/iu, + /контрагент\s*:\s*([^|\n]+)/iu + ]; + for (const pattern of patterns) { + const match = answerText.match(pattern); + const label = match?.[1] ? cleanExtractedCounterpartyLabel(match[1], itemLabel) : null; + if (label) { + return label; + } + } + return null; +} function buildAddressMemoryRecapReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const item = contextFacts.item; @@ -663,10 +753,25 @@ function buildSelectedObjectAnswerInspectionReply(input) { ].join(" "); } if (detectedIntent === "inventory_sale_trace_for_item") { + const priorAnswerText = findPriorAssistantAnswerForDebug({ + sessionItems: input.sessionItems, + addressDebug: input.addressDebug, + item: contextFacts.item, + detectedIntent, + toNonEmptyString: input.toNonEmptyString + }); + const buyerLabel = extractBuyerFromSaleTraceAnswer(priorAnswerText, contextFacts.item); + if (buyerLabel) { + return [ + `Нет, в данных это не ошибка: «${itemLabel}» здесь товар/номенклатура, а не контрагент.`, + `Контрагент-покупатель в предыдущем ответе был «${buyerLabel}».`, + "В строках вида «товар: ... | контрагент: ...» эти поля надо читать отдельно; я не должен трактовать название товара как контрагента." + ].join(" "); + } return [ - `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`, - "В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.", - "Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации." + `«${itemLabel}» здесь не контрагент, а товар/номенклатура, по которой мы смотрели продажу.`, + "Покупателя нужно читать из поля «контрагент» в строках реализации или из явной строки про покупателя, а не из названия товара.", + "Если в предыдущем ответе это прозвучало иначе, это ошибка пояснения, а не доказательство, что товар является контрагентом." ].join(" "); } if (detectedIntent === "inventory_purchase_provenance_for_item" || diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 22cc251..9cf0dc0 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -280,6 +280,7 @@ export async function runAssistantLivingChatRuntime( } else if (contextualAnswerInspectionFollowup) { chatText = buildSelectedObjectAnswerInspectionReplyFromPolicy({ addressDebug: lastAnswerInspectionAddressDebug, + sessionItems: input.sessionItems, toNonEmptyString: input.toNonEmptyString }); livingChatSource = "deterministic_answer_inspection_contract"; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 9931943..1579340 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -529,6 +529,42 @@ function businessOverviewNextStepLine(overview: BusinessOverview): string { return `Следующий шаг для полного бизнес-аудита: отдельно проверить ${target}, не смешивая эти будущие проверки с уже подтвержденным обзором.`; } +function businessOverviewStrongestIncomingYear(overview: BusinessOverview): NonNullable[number] | null { + const years = overview.yearly_breakdown ?? []; + return [...years] + .filter((bucket) => bucket.incoming_total_amount > 0) + .sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0] ?? null; +} + +function inlineBusinessOverviewAmount(value: string): string { + return String(value ?? "") + .trim() + .replace(/\s*руб\.$/u, " рублей") + .replace(/[\s.]+$/u, ""); +} + +function businessOverviewHeadlineMetricsLine(overview: BusinessOverview): string | null { + const parts: string[] = []; + if (overview.incoming_customer_revenue.rows_with_amount > 0) { + parts.push(`входящие поступления ${inlineBusinessOverviewAmount(overview.incoming_customer_revenue.total_amount_human_ru)}`); + } + if (overview.outgoing_supplier_payout.rows_with_amount > 0) { + parts.push(`исходящие платежи/списания ${inlineBusinessOverviewAmount(overview.outgoing_supplier_payout.total_amount_human_ru)}`); + } + if (overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0) { + parts.push(`расчетное операционное нетто ${inlineBusinessOverviewAmount(overview.net_amount_human_ru)}`); + } + const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); + if (strongestIncomingYear) { + parts.push( + `самый сильный год по подтвержденным входящим поступлениям ${strongestIncomingYear.year_bucket}: ${inlineBusinessOverviewAmount(strongestIncomingYear.incoming_total_amount_human_ru)}` + ); + } + return parts.length > 0 + ? `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат` + : null; +} + function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string { const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || @@ -611,6 +647,10 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD : "качество открытых расчетов" ); unknownFamilies.push(businessOverviewInventoryUnknownLabel(overview)); + const metricLead = businessOverviewHeadlineMetricsLine(overview); + if (metricLead) { + return `Ограниченный бизнес-обзор по подтвержденным строкам 1С: ${metricLead}. Проверенные контуры: ${families.join(", ")}; ${unknownFamilies.join(", ")} остаются отдельными непроверенными областями.`; + } return `По данным 1С собран ограниченный бизнес-обзор: ${families.join(", ")} подтверждены найденными строками; ${unknownFamilies.join(", ")} остаются отдельными непроверенными областями.`; } if (isBusinessOverviewPilot(pilot) && mode === "checked_sources_only") { @@ -1201,6 +1241,12 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot `Исходящие платежи/списания${organization}${period}: ${overview.outgoing_supplier_payout.total_amount_human_ru} по ${overview.outgoing_supplier_payout.rows_with_amount} строкам с суммой.` ); } + const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); + if (strongestIncomingYear) { + lines.push( + `Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} — ${strongestIncomingYear.incoming_total_amount_human_ru} по ${strongestIncomingYear.incoming_rows_with_amount} строкам с суммой. Это не бухгалтерская прибыль.` + ); + } const leader = overview.top_customers[0]; if (leader) { lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); @@ -1391,9 +1437,7 @@ function businessOverviewYearlyOperatingLine(overview: BusinessOverview): string if (years.length === 0) { return null; } - const strongestIncomingYear = [...years] - .filter((bucket) => bucket.incoming_total_amount > 0) - .sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0]; + const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); const strongestNetYear = [...years] .filter((bucket) => bucket.net_amount !== 0) .sort((left, right) => right.net_amount - left.net_amount || left.year_bucket.localeCompare(right.year_bucket))[0]; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index 2f697af..60a01d0 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -164,6 +164,23 @@ function hasAllTimeScopeHint(rawUtterance: string): boolean { ); } +function hasBusinessOverviewDirectMoneyAnswerHint(input: { + family: string | null; + rawUtterance: string; + rankingNeed: string | null; +}): boolean { + if (input.family !== "business_overview" || !input.rawUtterance) { + return false; + } + if (input.rankingNeed) { + return true; + } + const text = input.rawUtterance; + return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|how\s+much)[\s\S]{0,120}(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u0435\u043d\p{L}*|\u043f\u043e\u043b\u0443\u0447|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*)|(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447)[\s\S]{0,120}(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u0432\u0441\u0435\u0433\u043e|\u0432\u043e\u043e\u0431\u0449\u0435|(?:19|20)\d{2}|all\s+time)|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|which|what)[\s\S]{0,80}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,80}(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,40}(?:\u0433\u043e\u0434|year)/iu.test( + text + ); +} + function timeScopeNeedFor(input: { family: string | null; explicitDateScope: string | null; @@ -275,6 +292,24 @@ function rankingNeedFromRawUtterance(value: string): string | null { if (!text) { return null; } + if (/(?:\u0442\u043e\u043f[-\s]?\d*|\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)|\u0431\u043e\u043b\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u0431\u043e\u043b[\u0435\u0451]\u0435|\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c|\u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448|\u043b\u0443\u0447\u0448\u0438\u0439)/iu.test(text)) { + return "top_desc"; + } + if (/(?:\u043c\u0435\u043d\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u043c\u0435\u043d[\u044c\u0448]\u0435|\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0438\u043d\u0438\u043c\u0443\u043c|\u0445\u0443\u0434\u0448\u0438\u0439)/iu.test(text)) { + return "bottom_asc"; + } + if (/(?:\btop[-\s]?\d+\b|\btop\b|\u0442\u043e\u043f[-\s]?\d+|\u0442\u043e\u043f\b|\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)\b|\u0431\u043e\u043b\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u0431\u043e\u043b[\u0435\u0451]\u0435|\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c|\u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448|\u043b\u0443\u0447\u0448\u0438\u0439|highest|largest|most|best)/iu.test(text)) { + return "top_desc"; + } + if (/(?:\u043c\u0435\u043d\u044c\u0448\u0435\s+\u0432\u0441\u0435\u0433\u043e|\u043d\u0430\u0438\u043c\u0435\u043d[\u044c\u0448]\u0435|\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d|\u043c\u0438\u043d\u0438\u043c\u0443\u043c|\u0445\u0443\u0434\u0448\u0438\u0439|lowest|smallest|least|worst)/iu.test(text)) { + return "bottom_asc"; + } + if (/(?:\btop[-\s]?\d+\b|\btop\b|топ[-\s]?\d+|топ\b|сам(?:ый|ая|ое|ые)\b|больше\s+всего|наибол[её]е|максимальн|максимум|крупнейш|лучший|highest|largest|most|best)/iu.test(text)) { + return "top_desc"; + } + if (/(?:меньше\s+всего|наимен[ьш]е|минимальн|минимум|худший|lowest|smallest|least|worst)/iu.test(text)) { + return "bottom_asc"; + } if ( /(?:\btop[-\s]?\d+\b|\btop\b|топ[-\s]?\d+|топ\b|сам(?:ый|ая|ое|ые)\b|больше\s+всего|наибол[её]е|highest|largest|most)/iu.test( text @@ -480,6 +515,11 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const comparisonNeed = comparisonNeedFor(action); const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); + const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({ + family: businessFactFamily, + rawUtterance, + rankingNeed + }); const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action); const openScopeWithoutSubject = subjectCandidates.length === 0 && @@ -581,6 +621,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( if (businessFactFamily === "business_overview" && !explicitDateScope) { pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); } + if (directBusinessOverviewMoneyAnswerHint) { + pushReason(reasonCodes, "data_need_graph_business_overview_direct_money_answer"); + } if (clarificationGaps.includes("organization")) { pushReason(reasonCodes, "data_need_graph_open_scope_total_needs_organization"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 9319b38..40b4dda 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -334,6 +334,180 @@ function section(title: string, lines: string[]): string | null { return `${title}\n${clean.map((line) => `- ${line}`).join("\n")}`; } +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)) + : []; +} + +function moneyText(value: unknown): string | null { + const text = toNonEmptyString(value); + if (!text) { + return null; + } + return text.replace(/\s*руб\.$/u, " руб.").replace(/\s+/gu, " "); +} + +function sentenceAmount(value: string | null): string | null { + return value ? value.replace(/[.]+$/u, "") : null; +} + +function businessOverviewPeriodText(overview: Record): string { + const period = toNonEmptyString(overview.period_scope); + return period ? `за ${period}` : "за все доступное проверенное окно"; +} + +function strongestIncomingYear(overview: Record): Record | null { + const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; + const sorted = years + .map((item) => toRecordObject(item)) + .filter((item): item is Record => { + if (!item) { + return false; + } + return Number(item.incoming_total_amount) > 0; + }) + .sort((left, right) => { + const amountDelta = Number(right.incoming_total_amount) - Number(left.incoming_total_amount); + if (amountDelta !== 0) { + return amountDelta; + } + return String(left.year_bucket ?? "").localeCompare(String(right.year_bucket ?? "")); + }); + return sorted[0] ?? null; +} + +function strongestNetYear(overview: Record): Record | null { + const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; + const sorted = years + .map((item) => toRecordObject(item)) + .filter((item): item is Record => { + if (!item) { + return false; + } + return Number(item.net_amount) !== 0; + }) + .sort((left, right) => { + const amountDelta = Number(right.net_amount) - Number(left.net_amount); + if (amountDelta !== 0) { + return amountDelta; + } + return String(left.year_bucket ?? "").localeCompare(String(right.year_bucket ?? "")); + }); + return sorted[0] ?? null; +} + +function businessOverviewCoverageLimitLine(overview: Record): string | null { + const incoming = toRecordObject(overview.incoming_customer_revenue); + const outgoing = toRecordObject(overview.outgoing_supplier_payout); + const limited: string[] = []; + if (incoming?.coverage_limited_by_probe_limit === true) { + limited.push("входящие"); + } + if (outgoing?.coverage_limited_by_probe_limit === true) { + limited.push("исходящие"); + } + return limited.length > 0 + ? `Важно: ${limited.join(" и ")} уперлись в лимит выборки MCP, поэтому это проверенный срез найденных строк, а не гарантированно полный бухгалтерский оборот.` + : null; +} + +function businessOverviewYearRowsLine(overview: Record): string | null { + const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; + const values = years + .map((item) => toRecordObject(item)) + .filter((item): item is Record => Boolean(item)) + .slice(0, 6) + .map((item) => { + const year = toNonEmptyString(item.year_bucket); + const incoming = moneyText(item.incoming_total_amount_human_ru); + const net = moneyText(item.net_amount_human_ru); + const direction = item.net_direction === "net_outgoing" ? "нетто в минус" : "нетто в плюс"; + return year && incoming && net ? `${year}: входящие ${incoming}, ${direction} ${net}` : null; + }) + .filter((item): item is string => Boolean(item)); + const joined = values.join("; "); + return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null; +} + +function buildCompactBusinessOverviewReply( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract, + draft: Record +): string | null { + const turnInput = toRecordObject(entryPoint.turn_input); + const graph = toRecordObject(turnInput?.data_need_graph); + const bridge = toRecordObject(entryPoint.bridge); + const pilot = toRecordObject(bridge?.pilot); + const overview = toRecordObject(pilot?.derived_business_overview); + const graphReasons = readStringArray(graph?.reason_codes); + const isBusinessOverview = + toNonEmptyString(graph?.business_fact_family) === "business_overview" || + toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1"; + const rankingNeed = toNonEmptyString(graph?.ranking_need); + const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer"); + if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) { + return null; + } + + const incoming = toRecordObject(overview.incoming_customer_revenue); + const outgoing = toRecordObject(overview.outgoing_supplier_payout); + const incomingAmount = moneyText(incoming?.total_amount_human_ru); + const outgoingAmount = moneyText(outgoing?.total_amount_human_ru); + const netAmount = moneyText(overview.net_amount_human_ru); + const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто"; + const period = businessOverviewPeriodText(overview); + const limitLine = businessOverviewCoverageLimitLine(overview); + const lines: string[] = []; + + if (rankingNeed) { + const incomingLeader = strongestIncomingYear(overview); + const netLeader = strongestNetYear(overview); + const leaderYear = toNonEmptyString(incomingLeader?.year_bucket); + const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru); + const leaderRows = Number(incomingLeader?.incoming_rows_with_amount); + if (!leaderYear || !leaderAmount) { + return null; + } + lines.push( + `Коротко: самый доходный год в доступном денежном контуре 1С — ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.` + ); + const netYear = toNonEmptyString(netLeader?.year_bucket); + const netYearAmount = moneyText(netLeader?.net_amount_human_ru); + if (netYear && netYearAmount) { + const netLabel = netLeader?.net_direction === "net_outgoing" ? "нетто в минус" : "нетто в плюс"; + lines.push(`По расчетному операционному нетто лучший год: ${netYear}, ${netLabel} ${sentenceAmount(netYearAmount) ?? netYearAmount}.`); + } + lines.push('Метод: "доходный" здесь трактую как подтвержденные входящие поступления/выручку по найденным строкам 1С, не как чистую бухгалтерскую прибыль.'); + if (incomingAmount && outgoingAmount && netAmount) { + lines.push(`Сверка по окну: входящие ${incomingAmount}, исходящие ${outgoingAmount}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount}.`); + } + const yearRows = businessOverviewYearRowsLine(overview); + if (yearRows) { + lines.push(yearRows); + } + } else if (incomingAmount || outgoingAmount || netAmount) { + lines.push( + `Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.` + ); + lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); + const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null); + const customerName = toNonEmptyString(topCustomer?.axis_value); + const customerAmount = moneyText(topCustomer?.total_amount_human_ru); + if (customerName && customerAmount) { + lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`); + } + } else { + return null; + } + + if (limitLine) { + lines.push(limitLine); + } + lines.push("Для ответа именно про чистую прибыль нужно отдельно считать себестоимость, расходы и закрытие периода."); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; +} + function statusFrom(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null): AssistantMcpDiscoveryResponseCandidateStatus { if (!entryPoint || entryPoint.entry_status === "skipped_not_applicable") { return "not_applicable"; @@ -382,6 +556,11 @@ function buildReplyText(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContra return null; } + const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft); + if (compactBusinessOverviewReply) { + return compactBusinessOverviewReply; + } + const blocks = [ toNonEmptyString(draft.headline) ? `Коротко: ${localizeLine(String(draft.headline))}` : null, section("Что подтверждено:", toStringList(draft.confirmed_lines)), diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index cf243ce..6a62ba3 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -594,6 +594,125 @@ function collectBusinessEvaluationEvidence(input: { }; } +function readSessionAssistantText( + item: unknown, + toNonEmptyString: (value: unknown) => string | null +): string | null { + const record = toRecordObject(item); + if (!record) { + return null; + } + const direct = + toNonEmptyString(record.text) ?? + toNonEmptyString(record.chatText) ?? + toNonEmptyString(record.assistant_message) ?? + toNonEmptyString(record.answer) ?? + toNonEmptyString(record.output); + if (direct) { + return direct; + } + const humanReadable = toRecordObject(record.human_readable); + const technicalJson = toRecordObject(record.technical_json); + return ( + toNonEmptyString(humanReadable?.answer) ?? + toNonEmptyString(humanReadable?.assistant_message) ?? + toNonEmptyString(technicalJson?.assistant_message) + ); +} + +function readSessionItemTraceId( + item: unknown, + toNonEmptyString: (value: unknown) => string | null +): string | null { + const record = toRecordObject(item); + const debug = toRecordObject(record?.debug) ?? toRecordObject(toRecordObject(record?.technical_json)?.debug); + return toNonEmptyString(debug?.trace_id) ?? toNonEmptyString(record?.trace_id); +} + +function findPriorAssistantAnswerForDebug(input: { + sessionItems?: unknown[]; + addressDebug: Record | null; + item: string | null; + detectedIntent: string; + toNonEmptyString: (value: unknown) => string | null; +}): string | null { + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + if (sessionItems.length === 0) { + return null; + } + const targetTraceId = input.toNonEmptyString(input.addressDebug?.trace_id); + const targetItemKey = normalizeRecapIdentity(input.item); + let fallbackText: string | null = null; + + for (let index = sessionItems.length - 1; index >= 0; index -= 1) { + const record = toRecordObject(sessionItems[index]); + if (!record || record.role !== "assistant") { + continue; + } + const text = readSessionAssistantText(record, input.toNonEmptyString); + if (!text) { + continue; + } + const traceId = readSessionItemTraceId(record, input.toNonEmptyString); + if (targetTraceId && traceId === targetTraceId) { + return text; + } + const debug = toRecordObject(record.debug) ?? toRecordObject(toRecordObject(record.technical_json)?.debug); + const detectedIntent = String(debug?.detected_intent ?? ""); + const debugContext = resolveAddressDebugContextFacts(debug, input.toNonEmptyString); + const itemMatches = + targetItemKey && normalizeRecapIdentity(debugContext.item) === targetItemKey; + if (detectedIntent === input.detectedIntent && itemMatches && !fallbackText) { + fallbackText = text; + } + } + + return fallbackText; +} + +function cleanExtractedCounterpartyLabel( + value: string, + itemLabel: string | null +): string | null { + let text = String(value ?? "") + .replace(/\*\*/g, "") + .replace(/[«»"]/g, "") + .split("|")[0] + .split("\n")[0] + .trim(); + text = text.replace(/^[\s:.-]+/u, "").replace(/[\s,;:.]+$/u, "").trim(); + if (!text || /(?:не выделен|не найден|не определен|не подтвержден)/iu.test(text)) { + return null; + } + if (itemLabel && normalizeRecapIdentity(text) === normalizeRecapIdentity(itemLabel)) { + return null; + } + return text; +} + +function extractBuyerFromSaleTraceAnswer( + answerText: string | null, + itemLabel: string | null +): string | null { + if (!answerText) { + return null; + } + const patterns = [ + /покупатель\s+определ[её]н\s*:\s*([^\n]+)/iu, + /отгружал[^\n:]*покупател[^\n:]*:\s*([^\n]+)/iu, + /покупател[^\n:]*:\s*([^\n]+)/iu, + /контрагент\s*:\s*([^|\n]+)/iu + ]; + for (const pattern of patterns) { + const match = answerText.match(pattern); + const label = match?.[1] ? cleanExtractedCounterpartyLabel(match[1], itemLabel) : null; + if (label) { + return label; + } + } + return null; +} + export function buildAddressMemoryRecapReply(input: { organization: string | null; addressDebug: Record | null; @@ -808,6 +927,7 @@ export function buildConversationExecutiveSummaryReply(input: { export function buildSelectedObjectAnswerInspectionReply(input: { addressDebug: Record | null; + sessionItems?: unknown[]; toNonEmptyString: (value: unknown) => string | null; }): string { const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString); @@ -847,10 +967,25 @@ export function buildSelectedObjectAnswerInspectionReply(input: { } if (detectedIntent === "inventory_sale_trace_for_item") { + const priorAnswerText = findPriorAssistantAnswerForDebug({ + sessionItems: input.sessionItems, + addressDebug: input.addressDebug, + item: contextFacts.item, + detectedIntent, + toNonEmptyString: input.toNonEmptyString + }); + const buyerLabel = extractBuyerFromSaleTraceAnswer(priorAnswerText, contextFacts.item); + if (buyerLabel) { + return [ + `Нет, в данных это не ошибка: «${itemLabel}» здесь товар/номенклатура, а не контрагент.`, + `Контрагент-покупатель в предыдущем ответе был «${buyerLabel}».`, + "В строках вида «товар: ... | контрагент: ...» эти поля надо читать отдельно; я не должен трактовать название товара как контрагента." + ].join(" "); + } return [ - `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`, - "В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.", - "Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации." + `«${itemLabel}» здесь не контрагент, а товар/номенклатура, по которой мы смотрели продажу.`, + "Покупателя нужно читать из поля «контрагент» в строках реализации или из явной строки про покупателя, а не из названия товара.", + "Если в предыдущем ответе это прозвучало иначе, это ошибка пояснения, а не доказательство, что товар является контрагентом." ].join(" "); } diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index b7c3b55..209cdd9 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -637,7 +637,10 @@ describe("assistant living chat runtime adapter", () => { sessionItems: [ { role: "assistant", + text: "По товару Рабочая станция универсального специалиста покупатель определен: Комитет государственных услуг г. Москвы.\nДокументы выбытия:\n1. Реализация товаров и услуг | товар: Рабочая станция универсального специалиста | контрагент: Комитет государственных услуг г. Москвы", + trace_id: "address-sale-trace", debug: { + trace_id: "address-sale-trace", execution_lane: "address_query", answer_grounding_check: { status: "grounded" @@ -658,6 +661,8 @@ describe("assistant living chat runtime adapter", () => { expect(output.handled).toBe(true); expect(output.chatText).toContain("Рабочая станция универсального специалиста"); + expect(output.chatText).toContain("Комитет государственных услуг г. Москвы"); + expect(output.chatText).not.toContain("Покупатель в доступных данных отдельно не выделен"); expect(output.debug?.living_chat_response_source).toBe("deterministic_answer_inspection_contract"); expect(executeLlmChat).not.toHaveBeenCalled(); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 9d82475..0dafa5d 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -264,7 +264,12 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); expect(draft.headline).toContain("бизнес-обзор"); + expect(draft.headline).toContain("входящие поступления"); + expect(draft.headline).toContain("расчетное операционное нетто"); + expect(draft.headline).toContain("самый сильный год"); expect(draft.confirmed_lines.join("\n")).toContain("Входящие поступления"); + expect(draft.confirmed_lines.join("\n")).toContain("Самый сильный год"); + expect(draft.confirmed_lines.join("\n")).toContain("2021"); expect(draft.confirmed_lines.join("\n")).toContain("Самый крупный подтвержденный клиент"); expect(draft.confirmed_lines.join("\n")).toContain("Самый крупный подтвержденный поставщик"); expect(draft.confirmed_lines.join("\n")).toContain("Годовая раскладка операционного денежного потока"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 88547df..ce0f6ac 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -44,6 +44,149 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("primitive"); }); + it("uses a compact direct answer for business-overview top year questions", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: "top_desc", + reason_codes: [ + "data_need_graph_family_business_overview", + "data_need_graph_ranking_top_desc", + "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: null, + incoming_customer_revenue: { + total_amount_human_ru: "157 192 981,43 руб.", + coverage_limited_by_probe_limit: true + }, + outgoing_supplier_payout: { + total_amount_human_ru: "35 439 044,74 руб.", + coverage_limited_by_probe_limit: true + }, + net_amount_human_ru: "121 753 936,69 руб.", + net_direction: "net_incoming", + top_customers: [], + yearly_breakdown: [ + { + year_bucket: "2014", + incoming_total_amount: 11239150.41, + incoming_total_amount_human_ru: "11 239 150,41 руб.", + incoming_rows_with_amount: 4, + net_amount: -634486.17, + net_amount_human_ru: "634 486,17 руб.", + net_direction: "net_outgoing" + }, + { + year_bucket: "2015", + incoming_total_amount: 136723459.73, + incoming_total_amount_human_ru: "136 723 459,73 руб.", + incoming_rows_with_amount: 54, + net_amount: 113158051.57, + net_amount_human_ru: "113 158 051,57 руб.", + net_direction: "net_incoming" + } + ] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Ограниченный бизнес-обзор с большим полотном.", + confirmed_lines: ["Профиль операционной активности: лишняя широкая строка."], + inference_lines: ["Сводный LLM-аудит: лишняя широкая строка."], + unknown_lines: ["Прибыль и маржа не подтверждены."], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("самый доходный год"); + expect(candidate.reply_text).toContain("2015"); + expect(candidate.reply_text).toContain("136 723 459,73 руб."); + expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль"); + expect(candidate.reply_text).toContain("лимит выборки MCP"); + expect(candidate.reply_text).not.toContain("Что подтверждено:"); + expect(candidate.reply_text).not.toContain("Профиль операционной активности"); + }); + + it("uses a compact direct answer for business-overview earned-money totals", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + 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 руб.", + coverage_limited_by_probe_limit: false + }, + outgoing_supplier_payout: { + total_amount_human_ru: "4 458 027,05 руб.", + coverage_limited_by_probe_limit: true + }, + net_amount_human_ru: "12 474 036,91 руб.", + net_direction: "net_incoming", + top_customers: [ + { + axis_value: "ГКУ УКРиС", + total_amount_human_ru: "11 536 836,23 руб." + } + ], + yearly_breakdown: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Ограниченный бизнес-обзор с большим полотном.", + confirmed_lines: ["Складской срез на дату: лишняя широкая строка."], + inference_lines: ["Риски и контуры внимания: лишняя широкая строка."], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("за 2017"); + expect(candidate.reply_text).toContain("получили 16 932 063,96 руб."); + expect(candidate.reply_text).toContain("исходящие платежи/списания 4 458 027,05 руб."); + expect(candidate.reply_text).toContain("12 474 036,91 руб."); + expect(candidate.reply_text).toContain("денежный operating-flow proxy"); + expect(candidate.reply_text).not.toContain("Что можно сказать только как вывод:"); + expect(candidate.reply_text).not.toContain("Складской срез"); + }); + it("localizes value-flow evidence without leaking pilot mechanics", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 177f9da..b6d56b8 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1989,6 +1989,65 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.clarification_gaps).toEqual([]); expect(result.data_need_graph?.time_scope_need).toBe("all_time_scope"); expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_all_time_scope_hint"); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_business_overview_direct_money_answer"); + }); + + it.skip("marks organization-level top-year wording as a direct business-overview money answer", () => { + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "какой у нас самый доходный год", + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { + organization: orgName + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.ranking_need).toBe("top_desc"); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_business_overview_direct_money_answer"); + }); + + it("marks UTF-8 organization-level top-year wording as a direct business-overview money answer", () => { + 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: + "\u043a\u0430\u043a\u043e\u0439 \u0443 \u043d\u0430\u0441 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434", + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { + organization: orgName + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.ranking_need).toBe("top_desc"); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_business_overview_direct_money_answer"); + }); + + it("marks explicit-period earned-money wording as a direct business-overview money answer", () => { + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а за 2017 мы скок заработали?", + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { + organization: orgName + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2017"); + expect(result.data_need_graph?.reason_codes).toContain("data_need_graph_business_overview_direct_money_answer"); }); it("routes organization-level profit and margin wording to business overview instead of exact value recipes", () => { diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts index fb76061..23900bb 100644 --- a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -348,6 +348,26 @@ describe("assistantMemoryRecapPolicy", () => { const reply = buildSelectedObjectAnswerInspectionReply({ addressDebug: context.lastAnswerInspectionAddressDebug, + sessionItems: [ + { + role: "assistant", + text: "По товару Рабочая станция покупатель определен: Комитет государственных услуг г. Москвы.\nДокументы выбытия:\n1. Реализация товаров и услуг | товар: Рабочая станция | контрагент: Комитет государственных услуг г. Москвы", + trace_id: "address-sale-trace", + debug: { + trace_id: "address-sale-trace", + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_sale_trace_for_item", + extracted_filters: { + item: "Рабочая станция", + organization: "ООО Альтернатива Плюс", + as_of_date: "2016-03-31" + } + } + } + ], toNonEmptyString: (value: unknown) => { const text = String(value ?? "").trim(); return text.length > 0 ? text : null; @@ -357,7 +377,8 @@ describe("assistantMemoryRecapPolicy", () => { expect(context.contextualAnswerInspectionFollowup).toBe(true); expect(reply).toContain("не контрагент"); expect(reply).toContain("Рабочая станция"); - expect(reply).toContain("Покупатель"); + expect(reply).toContain("Комитет государственных услуг г. Москвы"); + expect(reply).not.toContain("Покупатель в доступных данных отдельно не выделен"); }); it("builds deterministic recap summary from grounded MCP discovery counterparty context", () => {