Сжать бизнес-ответы по доходности и заработанным суммам
This commit is contained in:
parent
a15cbb3fcb
commit
7c77db2c8d
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`,
|
||||
"В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.",
|
||||
"Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации."
|
||||
`Нет, в данных это не ошибка: «${itemLabel}» здесь товар/номенклатура, а не контрагент.`,
|
||||
`Контрагент-покупатель в предыдущем ответе был «${buyerLabel}».`,
|
||||
"В строках вида «товар: ... | контрагент: ...» эти поля надо читать отдельно; я не должен трактовать название товара как контрагента."
|
||||
].join(" ");
|
||||
}
|
||||
return [
|
||||
`«${itemLabel}» здесь не контрагент, а товар/номенклатура, по которой мы смотрели продажу.`,
|
||||
"Покупателя нужно читать из поля «контрагент» в строках реализации или из явной строки про покупателя, а не из названия товара.",
|
||||
"Если в предыдущем ответе это прозвучало иначе, это ошибка пояснения, а не доказательство, что товар является контрагентом."
|
||||
].join(" ");
|
||||
}
|
||||
if (detectedIntent === "inventory_purchase_provenance_for_item" ||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`,
|
||||
"В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.",
|
||||
"Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации."
|
||||
`Нет, в данных это не ошибка: «${itemLabel}» здесь товар/номенклатура, а не контрагент.`,
|
||||
`Контрагент-покупатель в предыдущем ответе был «${buyerLabel}».`,
|
||||
"В строках вида «товар: ... | контрагент: ...» эти поля надо читать отдельно; я не должен трактовать название товара как контрагента."
|
||||
].join(" ");
|
||||
}
|
||||
return [
|
||||
`«${itemLabel}» здесь не контрагент, а товар/номенклатура, по которой мы смотрели продажу.`,
|
||||
"Покупателя нужно читать из поля «контрагент» в строках реализации или из явной строки про покупателя, а не из названия товара.",
|
||||
"Если в предыдущем ответе это прозвучало иначе, это ошибка пояснения, а не доказательство, что товар является контрагентом."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("Годовая раскладка операционного денежного потока");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue