Open-World: усилить аналитический синтез бизнес-обзора

This commit is contained in:
2026-05-04 08:59:33 +03:00
parent 9b26bd05ef
commit 822baedcba
6 changed files with 275 additions and 36 deletions
@@ -822,6 +822,20 @@ function businessOverviewNetDirectionRu(direction) {
}
return "входящий и исходящий денежный поток в проверенном срезе примерно сбалансированы";
}
function amountHumanRu(value) {
const rounded = Math.round(Math.abs(value) * 100) / 100;
return `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(rounded)} руб.`;
}
function percentOfTotal(part, total) {
if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) {
return null;
}
return Math.round((part / total) * 10_000) / 100;
}
function percentText(part, total) {
const pct = percentOfTotal(part, total);
return pct === null ? null : `${pct}%`;
}
function derivedBusinessOverviewConfirmedLines(pilot) {
const overview = pilot.derived_business_overview;
if (!overview) {
@@ -888,19 +902,94 @@ function derivedBusinessOverviewConfirmedLines(pilot) {
}
return lines;
}
function derivedBusinessOverviewInferenceLine(pilot) {
function businessOverviewCashSynthesisLine(overview) {
const incoming = overview.incoming_customer_revenue;
const outgoing = overview.outgoing_supplier_payout;
if (incoming.rows_with_amount <= 0 && outgoing.rows_with_amount <= 0) {
return null;
}
const checkedOperationalScale = Math.abs(incoming.total_amount) + Math.abs(outgoing.total_amount);
return [
`Аналитический вывод по оборотам: проверенный операционный размах ${amountHumanRu(checkedOperationalScale)}; входящий поток ${incoming.total_amount_human_ru}, исходящий ${outgoing.total_amount_human_ru}.`,
`${businessOverviewNetDirectionRu(overview.net_direction)}; расчетное нетто ${overview.net_amount_human_ru}.`
].join(" ");
}
function businessOverviewCustomerConcentrationLine(overview) {
const leader = overview.top_customers[0];
if (!leader || overview.incoming_customer_revenue.total_amount <= 0) {
return null;
}
const share = percentText(leader.total_amount, overview.incoming_customer_revenue.total_amount);
return share
? `Концентрация входящего потока: крупнейший подтвержденный клиент ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru}). Это сигнал зависимости от клиента, а не полный customer-risk аудит.`
: `Крупнейший подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`;
}
function businessOverviewRiskSynthesisLine(overview) {
const signals = [];
if (overview.tax_position) {
const taxDirection = overview.tax_position.net_vat_direction === "vat_to_pay"
? `НДС к уплате ${overview.tax_position.net_vat_amount_human_ru}`
: overview.tax_position.net_vat_direction === "vat_to_recover_or_offset"
? `НДС к вычету/зачету ${overview.tax_position.net_vat_amount_human_ru}`
: "НДС-позиция сбалансирована";
signals.push(taxDirection);
}
if (overview.debt_position) {
const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable"
? `дебиторка больше кредиторки на ${overview.debt_position.net_debt_position_amount_human_ru}`
: overview.debt_position.net_debt_position_direction === "net_payable"
? `кредиторка больше дебиторки на ${overview.debt_position.net_debt_position_amount_human_ru}`
: "дебиторка и кредиторка сбалансированы";
signals.push(debtDirection);
}
if (overview.debt_open_settlement_quality?.concentration_top_contract_pct !== null && overview.debt_open_settlement_quality?.top_contracts[0]) {
const topContract = overview.debt_open_settlement_quality.top_contracts[0];
signals.push(`крупнейший открытый договор держит ${overview.debt_open_settlement_quality.concentration_top_contract_pct}% открытых остатков (${topContract.total_amount_human_ru})`);
}
if (overview.debt_open_settlement_quality?.age_signal?.max_age_days !== null && overview.debt_open_settlement_quality?.age_signal?.max_age_days !== undefined) {
signals.push(`самый старый договорный возрастной сигнал ${overview.debt_open_settlement_quality.age_signal.max_age_days} дн.`);
}
if (overview.inventory_position) {
signals.push(`складской остаток на дату ${overview.inventory_position.total_amount_human_ru}`);
if (overview.inventory_position.aging_signal?.max_age_days !== null && overview.inventory_position.aging_signal?.max_age_days !== undefined) {
signals.push(`самый старый складской purchase-date сигнал ${overview.inventory_position.aging_signal.max_age_days} дн.`);
}
}
return signals.length > 0
? `Риски и контуры внимания по подтвержденным данным: ${signals.join("; ")}.`
: null;
}
function businessOverviewExecutiveVerdictLine(overview) {
const hasCash = overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0;
const hasExtraSignals = Boolean(overview.tax_position ||
overview.debt_position ||
overview.debt_open_settlement_quality ||
overview.inventory_position);
if (!hasCash && !hasExtraSignals) {
return null;
}
const cashTone = overview.net_direction === "net_incoming"
? "операционно входящий поток сильнее исходящего"
: overview.net_direction === "net_outgoing"
? "операционно исходящий поток сильнее входящего, это зона внимания к расходам/закупкам"
: "операционный поток выглядит сбалансированным";
const evidenceTone = hasExtraSignals
? "часть налоговых, долговых или складских контуров уже отдельно проверена"
: "налоги, долги и склад еще не дают проверенного управленческого контекста";
return `Сводный LLM-аудит по подтвержденному: ${cashTone}; ${evidenceTone}. Это полезный управленческий срез по найденным строкам 1С, но не финальный вывод о прибыльности, марже или здоровье компании.`;
}
function derivedBusinessOverviewInferenceLines(pilot) {
const overview = pilot.derived_business_overview;
if (!overview) {
return null;
}
if (overview.incoming_customer_revenue.rows_with_amount <= 0 &&
overview.outgoing_supplier_payout.rows_with_amount <= 0) {
return null;
return [];
}
return [
`Расчетное нетто по найденным строкам: ${overview.net_amount_human_ru}; ${businessOverviewNetDirectionRu(overview.net_direction)}.`,
"Это нормальный операционный сигнал, но не прибыль и не маржа: для управленческого вывода нужны отдельные расходы, себестоимость, долги, налоги и склад."
].join(" ");
businessOverviewCashSynthesisLine(overview),
businessOverviewCustomerConcentrationLine(overview),
businessOverviewRiskSynthesisLine(overview),
businessOverviewExecutiveVerdictLine(overview),
"Это аналитическая интерпретация подтвержденных строк, а не прибыль и не маржа: для финального управленческого вывода нужны отдельные расходы, себестоимость, закрывающие документы, долги, налоги и складская оборачиваемость."
].filter((line) => Boolean(line));
}
function businessOverviewUnknownLines(pilot) {
if (!pilot.derived_business_overview) {
@@ -915,17 +1004,22 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
if (pilot.evidence.unknown_facts.length > 0) {
pushReason(reasonCodes, "answer_contains_unknown_fact_boundary");
}
if (pilot.evidence.inferred_facts.length > 0) {
pushReason(reasonCodes, "answer_contains_bounded_inference");
}
const derivedInferenceLine = derivedBusinessOverviewInferenceLine(pilot) ??
derivedActivityInferenceLine(pilot) ??
const businessOverviewInferenceLines = derivedBusinessOverviewInferenceLines(pilot);
const derivedInferenceLine = derivedActivityInferenceLine(pilot) ??
derivedMetadataInferenceLine(pilot) ??
derivedRankedValueFlowInferenceLine(pilot) ??
derivedEntityResolutionInferenceLine(pilot);
const inferenceLines = derivedInferenceLine
? [derivedInferenceLine]
: pilot.evidence.inferred_facts;
const inferenceLines = businessOverviewInferenceLines.length > 0
? businessOverviewInferenceLines
: derivedInferenceLine
? [derivedInferenceLine]
: pilot.evidence.inferred_facts;
if (inferenceLines.length > 0) {
pushReason(reasonCodes, "answer_contains_bounded_inference");
}
if (businessOverviewInferenceLines.length > 0) {
pushReason(reasonCodes, "answer_contains_business_overview_analyst_synthesis");
}
const derivedMetadataLine = derivedMetadataConfirmedLine(pilot);
const derivedEntityResolutionLine = derivedEntityResolutionConfirmedLine(pilot);
const derivedValueLine = derivedBidirectionalValueFlowConfirmedLine(pilot) ??
@@ -25,6 +25,8 @@ export interface AssistantMcpDiscoveryAnswerDraftContract {
reason_codes: string[];
}
type BusinessOverview = NonNullable<AssistantMcpDiscoveryPilotExecutionContract["derived_business_overview"]>;
function normalizeReasonCode(value: string): string | null {
const normalized = value
.trim()
@@ -965,6 +967,23 @@ function businessOverviewNetDirectionRu(direction: "net_incoming" | "net_outgoin
return "входящий и исходящий денежный поток в проверенном срезе примерно сбалансированы";
}
function amountHumanRu(value: number): string {
const rounded = Math.round(Math.abs(value) * 100) / 100;
return `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(rounded)} руб.`;
}
function percentOfTotal(part: number, total: number): number | null {
if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) {
return null;
}
return Math.round((part / total) * 10_000) / 100;
}
function percentText(part: number, total: number): string | null {
const pct = percentOfTotal(part, total);
return pct === null ? null : `${pct}%`;
}
function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
const overview = pilot.derived_business_overview;
if (!overview) {
@@ -1052,21 +1071,103 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot
return lines;
}
function derivedBusinessOverviewInferenceLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
function businessOverviewCashSynthesisLine(overview: BusinessOverview): string | null {
const incoming = overview.incoming_customer_revenue;
const outgoing = overview.outgoing_supplier_payout;
if (incoming.rows_with_amount <= 0 && outgoing.rows_with_amount <= 0) {
return null;
}
const checkedOperationalScale = Math.abs(incoming.total_amount) + Math.abs(outgoing.total_amount);
return [
`Аналитический вывод по оборотам: проверенный операционный размах ${amountHumanRu(checkedOperationalScale)}; входящий поток ${incoming.total_amount_human_ru}, исходящий ${outgoing.total_amount_human_ru}.`,
`${businessOverviewNetDirectionRu(overview.net_direction)}; расчетное нетто ${overview.net_amount_human_ru}.`
].join(" ");
}
function businessOverviewCustomerConcentrationLine(overview: BusinessOverview): string | null {
const leader = overview.top_customers[0];
if (!leader || overview.incoming_customer_revenue.total_amount <= 0) {
return null;
}
const share = percentText(leader.total_amount, overview.incoming_customer_revenue.total_amount);
return share
? `Концентрация входящего потока: крупнейший подтвержденный клиент ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru}). Это сигнал зависимости от клиента, а не полный customer-risk аудит.`
: `Крупнейший подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`;
}
function businessOverviewRiskSynthesisLine(overview: BusinessOverview): string | null {
const signals: string[] = [];
if (overview.tax_position) {
const taxDirection =
overview.tax_position.net_vat_direction === "vat_to_pay"
? `НДС к уплате ${overview.tax_position.net_vat_amount_human_ru}`
: overview.tax_position.net_vat_direction === "vat_to_recover_or_offset"
? `НДС к вычету/зачету ${overview.tax_position.net_vat_amount_human_ru}`
: "НДС-позиция сбалансирована";
signals.push(taxDirection);
}
if (overview.debt_position) {
const debtDirection =
overview.debt_position.net_debt_position_direction === "net_receivable"
? `дебиторка больше кредиторки на ${overview.debt_position.net_debt_position_amount_human_ru}`
: overview.debt_position.net_debt_position_direction === "net_payable"
? `кредиторка больше дебиторки на ${overview.debt_position.net_debt_position_amount_human_ru}`
: "дебиторка и кредиторка сбалансированы";
signals.push(debtDirection);
}
if (overview.debt_open_settlement_quality?.concentration_top_contract_pct !== null && overview.debt_open_settlement_quality?.top_contracts[0]) {
const topContract = overview.debt_open_settlement_quality.top_contracts[0];
signals.push(`крупнейший открытый договор держит ${overview.debt_open_settlement_quality.concentration_top_contract_pct}% открытых остатков (${topContract.total_amount_human_ru})`);
}
if (overview.debt_open_settlement_quality?.age_signal?.max_age_days !== null && overview.debt_open_settlement_quality?.age_signal?.max_age_days !== undefined) {
signals.push(`самый старый договорный возрастной сигнал ${overview.debt_open_settlement_quality.age_signal.max_age_days} дн.`);
}
if (overview.inventory_position) {
signals.push(`складской остаток на дату ${overview.inventory_position.total_amount_human_ru}`);
if (overview.inventory_position.aging_signal?.max_age_days !== null && overview.inventory_position.aging_signal?.max_age_days !== undefined) {
signals.push(`самый старый складской purchase-date сигнал ${overview.inventory_position.aging_signal.max_age_days} дн.`);
}
}
return signals.length > 0
? `Риски и контуры внимания по подтвержденным данным: ${signals.join("; ")}.`
: null;
}
function businessOverviewExecutiveVerdictLine(overview: BusinessOverview): string | null {
const hasCash = overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0;
const hasExtraSignals = Boolean(
overview.tax_position ||
overview.debt_position ||
overview.debt_open_settlement_quality ||
overview.inventory_position
);
if (!hasCash && !hasExtraSignals) {
return null;
}
const cashTone =
overview.net_direction === "net_incoming"
? "операционно входящий поток сильнее исходящего"
: overview.net_direction === "net_outgoing"
? "операционно исходящий поток сильнее входящего, это зона внимания к расходам/закупкам"
: "операционный поток выглядит сбалансированным";
const evidenceTone = hasExtraSignals
? "часть налоговых, долговых или складских контуров уже отдельно проверена"
: "налоги, долги и склад еще не дают проверенного управленческого контекста";
return `Сводный LLM-аудит по подтвержденному: ${cashTone}; ${evidenceTone}. Это полезный управленческий срез по найденным строкам 1С, но не финальный вывод о прибыльности, марже или здоровье компании.`;
}
function derivedBusinessOverviewInferenceLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
const overview = pilot.derived_business_overview;
if (!overview) {
return null;
}
if (
overview.incoming_customer_revenue.rows_with_amount <= 0 &&
overview.outgoing_supplier_payout.rows_with_amount <= 0
) {
return null;
return [];
}
return [
`Расчетное нетто по найденным строкам: ${overview.net_amount_human_ru}; ${businessOverviewNetDirectionRu(overview.net_direction)}.`,
"Это нормальный операционный сигнал, но не прибыль и не маржа: для управленческого вывода нужны отдельные расходы, себестоимость, долги, налоги и склад."
].join(" ");
businessOverviewCashSynthesisLine(overview),
businessOverviewCustomerConcentrationLine(overview),
businessOverviewRiskSynthesisLine(overview),
businessOverviewExecutiveVerdictLine(overview),
"Это аналитическая интерпретация подтвержденных строк, а не прибыль и не маржа: для финального управленческого вывода нужны отдельные расходы, себестоимость, закрывающие документы, долги, налоги и складская оборачиваемость."
].filter((line): line is string => Boolean(line));
}
function businessOverviewUnknownLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
@@ -1085,18 +1186,23 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
if (pilot.evidence.unknown_facts.length > 0) {
pushReason(reasonCodes, "answer_contains_unknown_fact_boundary");
}
if (pilot.evidence.inferred_facts.length > 0) {
pushReason(reasonCodes, "answer_contains_bounded_inference");
}
const businessOverviewInferenceLines = derivedBusinessOverviewInferenceLines(pilot);
const derivedInferenceLine =
derivedBusinessOverviewInferenceLine(pilot) ??
derivedActivityInferenceLine(pilot) ??
derivedMetadataInferenceLine(pilot) ??
derivedRankedValueFlowInferenceLine(pilot) ??
derivedEntityResolutionInferenceLine(pilot);
const inferenceLines = derivedInferenceLine
const inferenceLines = businessOverviewInferenceLines.length > 0
? businessOverviewInferenceLines
: derivedInferenceLine
? [derivedInferenceLine]
: pilot.evidence.inferred_facts;
if (inferenceLines.length > 0) {
pushReason(reasonCodes, "answer_contains_bounded_inference");
}
if (businessOverviewInferenceLines.length > 0) {
pushReason(reasonCodes, "answer_contains_business_overview_analyst_synthesis");
}
const derivedMetadataLine = derivedMetadataConfirmedLine(pilot);
const derivedEntityResolutionLine = derivedEntityResolutionConfirmedLine(pilot);
const derivedValueLine =
@@ -239,11 +239,15 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.headline).toContain("бизнес-обзор");
expect(draft.confirmed_lines.join("\n")).toContain("Входящие поступления");
expect(draft.confirmed_lines.join("\n")).toContain("Самый крупный подтвержденный клиент");
expect(draft.inference_lines.join("\n")).toContain("Аналитический вывод по оборотам");
expect(draft.inference_lines.join("\n")).toContain("Концентрация входящего потока");
expect(draft.inference_lines.join("\n")).toContain("Сводный LLM-аудит");
expect(draft.inference_lines.join("\n")).toContain("не прибыль и не маржа");
expect(draft.unknown_lines.join("\n")).toContain("Прибыль и маржа");
expect(draft.unknown_lines.join("\n")).toContain("Налоговая/VAT-позиция");
expect(draft.must_not_claim).toContain("Do not present business overview cash-flow spread as profit or margin.");
expect(draft.reason_codes).toContain("answer_contains_business_overview");
expect(draft.reason_codes).toContain("answer_contains_business_overview_analyst_synthesis");
});
it("surfaces checked VAT/tax position in business overview without treating it as profit", async () => {
@@ -386,10 +390,14 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.confirmed_lines.join("\n")).toContain("Возрастной сигнал открытых расчетов");
expect(draft.confirmed_lines.join("\n")).toContain("не due-date анализ");
expect(draft.confirmed_lines.join("\n")).toContain("нетто");
expect(draft.inference_lines.join("\n")).toContain("Риски и контуры внимания");
expect(draft.inference_lines.join("\n")).toContain("самый старый договорный возрастной сигнал");
expect(draft.inference_lines.join("\n")).toContain("Сводный LLM-аудит");
expect(draft.unknown_lines.join("\n")).toContain("due-date");
expect(draft.reason_codes).toContain("answer_contains_business_overview_debt_position");
expect(draft.reason_codes).toContain("answer_contains_business_overview_open_settlement_quality");
expect(draft.reason_codes).toContain("answer_contains_business_overview_debt_age_signal");
expect(draft.reason_codes).toContain("answer_contains_business_overview_analyst_synthesis");
expect(draft.must_not_claim).toContain("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis.");
expect(draft.must_not_claim).toContain("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt.");
});