Сжать бизнес-ответы по доходности и заработанным суммам

This commit is contained in:
dctouch 2026-05-09 10:30:46 +03:00
parent a15cbb3fcb
commit 7c77db2c8d
15 changed files with 987 additions and 13 deletions

View File

@ -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";

View File

@ -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];

View File

@ -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");
}

View File

@ -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)),

View File

@ -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" ||

View File

@ -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";

View File

@ -529,6 +529,42 @@ function businessOverviewNextStepLine(overview: BusinessOverview): string {
return `Следующий шаг для полного бизнес-аудита: отдельно проверить ${target}, не смешивая эти будущие проверки с уже подтвержденным обзором.`;
}
function businessOverviewStrongestIncomingYear(overview: BusinessOverview): NonNullable<BusinessOverview["yearly_breakdown"]>[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];

View File

@ -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");
}

View File

@ -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, unknown>): string {
const period = toNonEmptyString(overview.period_scope);
return period ? `за ${period}` : "за все доступное проверенное окно";
}
function strongestIncomingYear(overview: Record<string, unknown>): Record<string, unknown> | null {
const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : [];
const sorted = years
.map((item) => toRecordObject(item))
.filter((item): item is Record<string, unknown> => {
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<string, unknown>): Record<string, unknown> | null {
const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : [];
const sorted = years
.map((item) => toRecordObject(item))
.filter((item): item is Record<string, unknown> => {
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, unknown>): 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, unknown>): string | null {
const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : [];
const values = years
.map((item) => toRecordObject(item))
.filter((item): item is Record<string, unknown> => 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, unknown>
): 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)),

View File

@ -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<string, unknown> | 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<string, unknown> | null;
@ -808,6 +927,7 @@ export function buildConversationExecutiveSummaryReply(input: {
export function buildSelectedObjectAnswerInspectionReply(input: {
addressDebug: Record<string, unknown> | 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(" ");
}

View File

@ -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();
});

View File

@ -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("Годовая раскладка операционного денежного потока");

View File

@ -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({

View File

@ -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", () => {

View File

@ -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", () => {