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

This commit is contained in:
2026-05-09 10:30:46 +03:00
parent a15cbb3fcb
commit 7c77db2c8d
15 changed files with 987 additions and 13 deletions
@@ -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}» здесь товар/номенклатура, а не контрагент.`,
`Контрагент-покупатель в предыдущем ответе был «${buyerLabel}».`,
"В строках вида «товар: ... | контрагент: ...» эти поля надо читать отдельно; я не должен трактовать название товара как контрагента."
].join(" ");
}
return [
`Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`,
"В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.",
"Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации."
`«${itemLabel}» здесь не контрагент, а товар/номенклатура, по которой мы смотрели продажу.`,
"Покупателя нужно читать из поля «контрагент» в строках реализации или из явной строки про покупателя, а не из названия товара.",
"Если в предыдущем ответе это прозвучало иначе, это ошибка пояснения, а не доказательство, что товар является контрагентом."
].join(" ");
}