Усилить answer contract и агентный аудит для phase105
This commit is contained in:
parent
9c86407937
commit
bbc257fd6c
|
|
@ -1008,7 +1008,8 @@ function loadSessionDialog(runId, caseId) {
|
|||
text: toStringSafe(item.text) ?? "",
|
||||
created_at: toStringSafe(item.created_at),
|
||||
trace_id: toStringSafe(item.trace_id),
|
||||
reply_type: toStringSafe(item.reply_type)
|
||||
reply_type: toStringSafe(item.reply_type),
|
||||
debug: item.debug ?? null
|
||||
}));
|
||||
const turns = toArray(record.turns)
|
||||
.map((item) => toRecord(item))
|
||||
|
|
@ -1076,7 +1077,8 @@ function buildFallbackDialog(run, caseId) {
|
|||
text: userText,
|
||||
created_at: null,
|
||||
trace_id: null,
|
||||
reply_type: null
|
||||
reply_type: null,
|
||||
debug: null
|
||||
},
|
||||
{
|
||||
message_id: null,
|
||||
|
|
@ -1084,7 +1086,8 @@ function buildFallbackDialog(run, caseId) {
|
|||
text: assistantSummaryParts.join("\n"),
|
||||
created_at: null,
|
||||
trace_id: toStringSafe(targetCase.trace_id),
|
||||
reply_type: toStringSafe(targetCase.reply_type)
|
||||
reply_type: toStringSafe(targetCase.reply_type),
|
||||
debug: null
|
||||
}
|
||||
],
|
||||
decomposition: [],
|
||||
|
|
|
|||
|
|
@ -365,6 +365,63 @@ function bankOperationEvidenceLine(rows, preferredDirection = null) {
|
|||
}
|
||||
return `Основание 1С: ${parts.join("; ")}.`;
|
||||
}
|
||||
function classifyBankOperationSemanticBucket(row) {
|
||||
const text = [
|
||||
row.registrator,
|
||||
row.operation_kind,
|
||||
row.payment_purpose,
|
||||
row.contract,
|
||||
row.comment
|
||||
]
|
||||
.map((item) => String(item ?? "").toLowerCase())
|
||||
.join(" ");
|
||||
if (/(?:комисс|тариф|эквайр|обслуживан)/iu.test(text)) {
|
||||
return "commission";
|
||||
}
|
||||
if (/(?:депозит|кредит|займ|овердрафт|процент|ссуд)/iu.test(text)) {
|
||||
return "deposit_or_credit";
|
||||
}
|
||||
if (/(?:налог|ндс|взнос|бюджет|фнс|пфр|страхов)/iu.test(text)) {
|
||||
return "tax_or_budget";
|
||||
}
|
||||
if (/(?:возврат|перевод|перечислен|переброс|пополн|инкасс|перенос)/iu.test(text)) {
|
||||
return "transfer_or_return";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
function bankOperationSemanticBucketLabel(bucket) {
|
||||
if (bucket === "commission") {
|
||||
return "комиссии и банковое обслуживание";
|
||||
}
|
||||
if (bucket === "deposit_or_credit") {
|
||||
return "депозиты, кредиты или проценты";
|
||||
}
|
||||
if (bucket === "tax_or_budget") {
|
||||
return "налоги и бюджетные платежи";
|
||||
}
|
||||
if (bucket === "transfer_or_return") {
|
||||
return "переводы, возвраты или перебросы";
|
||||
}
|
||||
return "прочие банковские операции";
|
||||
}
|
||||
function summarizeBankOperationSemantics(rows) {
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const counts = new Map();
|
||||
for (const row of rows) {
|
||||
const bucket = classifyBankOperationSemanticBucket(row);
|
||||
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
|
||||
}
|
||||
const ranked = Array.from(counts.entries())
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.slice(0, 3);
|
||||
if (ranked.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const parts = ranked.map(([bucket, count]) => `${bankOperationSemanticBucketLabel(bucket)} — ${count}`);
|
||||
return `По смыслу это скорее финансовый/банковский контур: ${parts.join("; ")}.`;
|
||||
}
|
||||
function bankRoleBoundaryLine(userMessage, rows) {
|
||||
const incomingBoundary = hasBankIncomingRoleBoundaryQuestion(userMessage);
|
||||
const outgoingBoundary = hasBankOutgoingRoleBoundaryQuestion(userMessage);
|
||||
|
|
@ -3931,13 +3988,31 @@ function composeFactualReplyBody(intent, rows, options = {}) {
|
|||
.filter((item) => Boolean(item)));
|
||||
const counterparty = resolvePreferredCounterpartyDisplayLabel(options.counterpartyHint, rowCounterparties);
|
||||
const roleBoundary = bankRoleBoundaryLine(options.userMessage, rows);
|
||||
const visibleRows = rows.slice(0, Math.min(rows.length, 5));
|
||||
const visibleRows = [...rows]
|
||||
.sort((left, right) => Math.abs(right.amount ?? 0) - Math.abs(left.amount ?? 0) ||
|
||||
(String(right.period ?? "").localeCompare(String(left.period ?? ""), "ru")))
|
||||
.slice(0, Math.min(rows.length, 5));
|
||||
const semanticSummary = summarizeBankOperationSemantics(rows);
|
||||
const compactEvidenceRows = visibleRows.map((row, index) => {
|
||||
const direction = bankOperationDirectionLabel(bankOperationDirection(row));
|
||||
const amount = formatMoneyRub(row.amount ?? 0);
|
||||
const period = row.period ? formatDateRu(row.period) : "дата не указана";
|
||||
const operationKind = String(row.operation_kind ?? "").trim();
|
||||
const paymentPurpose = String(row.payment_purpose ?? "").trim();
|
||||
const detail = operationKind || paymentPurpose
|
||||
? ` | ${[operationKind, paymentPurpose].filter(Boolean).join("; ")}`
|
||||
: "";
|
||||
return `${index + 1}. ${period} | ${direction} | ${amount}${detail}`;
|
||||
});
|
||||
const lines = [
|
||||
`Коротко: найдено банковских операций${counterparty ? ` по ${counterparty}` : " по контрагенту"} — ${rows.length}.`,
|
||||
summarizeBankOperationDirections(rows),
|
||||
roleBoundary ?? "Показываю подтвержденные банковские операции из текущего среза.",
|
||||
bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)),
|
||||
...formatTopRows(visibleRows, visibleRows.length)
|
||||
...(semanticSummary ? [semanticSummary] : []),
|
||||
"Примеры строк 1С:",
|
||||
...compactEvidenceRows,
|
||||
"Следующий шаг: могу отдельно разложить назначения платежа, договоры или отделить банковский контур от клиентского/поставщицкого."
|
||||
];
|
||||
if (rows.length > visibleRows.length) {
|
||||
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length}; полный список остается в подтвержденном срезе.`);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,23 @@ function findFocusedCounterpartyValuePoint(profileRows, counterpartyHint, deps)
|
|||
}
|
||||
return profileRows.length === 1 ? profileRows[0] : null;
|
||||
}
|
||||
function hasProfitAmbiguityCue(normalizedQuestion) {
|
||||
return /(?:заработ|прибыл|прибыль|доход|выручк)/iu.test(normalizedQuestion);
|
||||
}
|
||||
function buildCashflowBoundaryLine(isSupplier) {
|
||||
return isSupplier
|
||||
? "Граница ответа: это подтвержденный денежный поток по поставщику, а не итоговая задолженность."
|
||||
: "Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.";
|
||||
}
|
||||
function buildCashflowNextStepLine(isSupplier, normalizedQuestion) {
|
||||
if (isSupplier) {
|
||||
return "Следующий шаг: могу отдельно показать остаток долга, просрочку или расшифровку по документам.";
|
||||
}
|
||||
if (hasProfitAmbiguityCue(normalizedQuestion)) {
|
||||
return "Следующий шаг: могу отдельно проверить чистую прибыль по закрытию 90/91/99.";
|
||||
}
|
||||
return "Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.";
|
||||
}
|
||||
function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
||||
if (intent === "counterparty_population_and_roles") {
|
||||
const rowsByMarker = groupRowsByMarker(rows);
|
||||
|
|
@ -396,6 +413,8 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
|||
/(?:клиент|заказчик|покупател|контрагент|customer|client|counterparty|buyer)/iu.test(normalizedQuestion);
|
||||
const semanticSingleBestCounterparty = focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList;
|
||||
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
|
||||
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
|
||||
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
|
||||
const byCounterparty = new Map();
|
||||
const byYear = new Map();
|
||||
const deals = [];
|
||||
|
|
@ -490,10 +509,12 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
|||
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
|
||||
: "за доступное время";
|
||||
const directAnswerLine = isSupplier
|
||||
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.`
|
||||
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`;
|
||||
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям.`
|
||||
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
cashflowBoundaryLine,
|
||||
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Контрагент в выборке: ${focusedCounterparty.name}.`,
|
||||
|
|
@ -511,11 +532,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
|||
const periodLine = options.periodFrom && options.periodTo
|
||||
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
periodLine,
|
||||
cashflowBoundaryLine,
|
||||
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
|
|
@ -538,11 +558,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
|||
const strongestYear = visible[0];
|
||||
const directAnswerLine = isSupplier
|
||||
? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`
|
||||
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`;
|
||||
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`;
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (!isSupplier) {
|
||||
lines.unshift(cashflowBoundaryLine);
|
||||
if (cashflowNextStepLine) {
|
||||
lines.unshift(cashflowNextStepLine);
|
||||
}
|
||||
}
|
||||
lines.unshift(directAnswerLine);
|
||||
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
|
||||
}
|
||||
|
|
@ -622,11 +648,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
|
|||
const directAnswerLine = singleCandidateOnly
|
||||
? isSupplier
|
||||
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
|
||||
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
|
||||
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
|
||||
: isSupplier
|
||||
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
|
||||
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
|
||||
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
|
||||
lines.unshift(directAnswerLine);
|
||||
if (!isSupplier) {
|
||||
lines.splice(1, 0, cashflowBoundaryLine);
|
||||
if (cashflowNextStepLine) {
|
||||
lines.splice(2, 0, cashflowNextStepLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(...visible.map((item, index) => {
|
||||
const avgCheck = item.ops > 0 ? item.total / item.ops : 0;
|
||||
|
|
|
|||
|
|
@ -87,10 +87,9 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
const positions = deps.buildInventoryOnHandAggregate(rows, asOfDate);
|
||||
const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item));
|
||||
const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
|
||||
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
|
||||
const directAnswerLine = positions.length > 0
|
||||
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
|
||||
const lines = [directAnswerLine];
|
||||
if (positions.length > 0) {
|
||||
|
|
@ -115,11 +114,14 @@ function composeInventoryReply(intent, rows, options, deps) {
|
|||
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
|
||||
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
|
||||
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
|
||||
`Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`
|
||||
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
|
||||
]);
|
||||
if (rows.length !== positions.length) {
|
||||
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
|
||||
}
|
||||
if (positions.length > 0) {
|
||||
lines.push("- Следующий шаг: могу раскрыть полный список, разложить остатки по складам или сравнить с другой датой.");
|
||||
}
|
||||
return positions.length > 0
|
||||
? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong"))
|
||||
: (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium"));
|
||||
|
|
|
|||
|
|
@ -942,6 +942,29 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
|
|||
return joinBusinessReplyLines(lines);
|
||||
}
|
||||
if (rankingNeed) {
|
||||
const explicitPeriodRankingOverview = period &&
|
||||
!/(?:все\s+доступное|все\s+время|all\s+time)/iu.test(period) &&
|
||||
(incomingAmount || outgoingAmount || netAmount);
|
||||
if (explicitPeriodRankingOverview) {
|
||||
lines.push(`Коротко: ${organizationPrefix}${period} денежная картина подтверждена по найденным строкам 1С.`);
|
||||
lines.push(`Деньги: входящие ${incomingAmount ?? "0 руб."}, исходящие ${outgoingAmount ?? "0 руб."}, расчетное операционное нетто ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`);
|
||||
if (customerName && customerAmount) {
|
||||
lines.push(topCustomerLooksFinancial
|
||||
? `Топ входящих: 1. ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}. Это финансовый/банковский контур, не считаю его клиентской выручкой без назначения платежа.${nonFinancialCustomer ? ` 2. Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
|
||||
: `Крупнейший входящий контрагент: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`);
|
||||
}
|
||||
if (topSupplier) {
|
||||
lines.push(topSupplierLooksFinancial
|
||||
? `Топ исходящих: 1. ${topSupplier}. Это финансовый/банковский контур, не считаю его обычным поставщиком без назначения платежа и договора.${nonFinancialSupplier ? ` 2. Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}`
|
||||
: `Крупнейший получатель исходящих денег: ${topSupplier}.`);
|
||||
}
|
||||
lines.push(`Вывод: по движению денег период ${netDirection}; это не чистая прибыль и не бухгалтерский финрезультат.`);
|
||||
if (requestedFinancialBoundaryLine) {
|
||||
lines.push(requestedFinancialBoundaryLine);
|
||||
}
|
||||
lines.push("Следующий шаг: могу отдельно посчитать чистую прибыль через закрытие 90/91/99 или разложить этот период по контрагентам.");
|
||||
return joinBusinessReplyLines(lines);
|
||||
}
|
||||
const incomingLeader = strongestIncomingYear(overview);
|
||||
const canRankYearlyNet = !limitLine;
|
||||
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
|
||||
|
|
|
|||
|
|
@ -1139,13 +1139,38 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
const rawEffectiveText = toNonEmptyString(input.effectiveMessage);
|
||||
const repairedUserText = rawUserText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawUserText) : null;
|
||||
const repairedEffectiveText = rawEffectiveText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawEffectiveText) : null;
|
||||
const rawUserSignalSourceText = repairedUserText ?? rawUserText ?? "";
|
||||
const rawSignalSourceText = `${repairedUserText ?? rawUserText ?? ""} ${repairedEffectiveText ?? rawEffectiveText ?? ""}`.trim();
|
||||
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
|
||||
const rawUserEntitySourceText = rawUserSignalSourceText || rawEntitySourceText;
|
||||
const rawUserTextOnly = compactLower(rawUserSignalSourceText);
|
||||
const rawAssistantEntityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates);
|
||||
const rawUserPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawUserTextOnly);
|
||||
const rawUserLifecyclePivotTextSignal = !rawUserPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawUserTextOnly);
|
||||
const rawUserBidirectionalValueFlowPivotTextSignal = !rawUserPrimaryBusinessOverviewSignal &&
|
||||
!rawUserLifecyclePivotTextSignal &&
|
||||
hasBidirectionalValueFlowSignal(rawUserTextOnly);
|
||||
const rawUserScopedEntityCandidate = rawUserSignalSourceText
|
||||
? rawScopedEntityCandidateFromText(rawUserEntitySourceText)
|
||||
: null;
|
||||
const rawUserCounterpartyBidirectionalOverride = Boolean(rawUserBidirectionalValueFlowPivotTextSignal &&
|
||||
(rawUserScopedEntityCandidate ||
|
||||
predecomposeEntities.counterparty ||
|
||||
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate))));
|
||||
const rawText = compactLower(rawSignalSourceText);
|
||||
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? "");
|
||||
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
|
||||
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) && !rawUserCounterpartyBidirectionalOverride;
|
||||
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
|
||||
const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText);
|
||||
const rawLifecyclePivotTextSignal = !rawPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawText);
|
||||
const rawBidirectionalValueFlowPivotTextSignal = !rawPrimaryBusinessOverviewSignal &&
|
||||
!rawLifecyclePivotTextSignal &&
|
||||
hasBidirectionalValueFlowSignal(rawText);
|
||||
const rawValueFlowPivotTextSignal = !rawPrimaryBusinessOverviewSignal &&
|
||||
!rawLifecyclePivotTextSignal &&
|
||||
(hasValueFlowSignal(rawText) ||
|
||||
hasValueRankingSignal(rawText) ||
|
||||
rawBidirectionalValueFlowPivotTextSignal);
|
||||
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal);
|
||||
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) &&
|
||||
hasBusinessOverviewContinuationSignal(rawText) &&
|
||||
|
|
@ -1163,7 +1188,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
hasMetadataSignal(rawText);
|
||||
const rawEntityResolutionSignal = !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
|
||||
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
|
||||
const rawValueFlowAggregateQuestionSignal = rawValueFlowSignal && hasValueFlowAggregateQuestionSignal(rawText);
|
||||
const rawValueFlowAggregateQuestionSignal = (rawValueFlowSignal || rawValueFlowPivotTextSignal) && hasValueFlowAggregateQuestionSignal(rawText);
|
||||
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
|
||||
const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText);
|
||||
const dateScopeSignalText = stripNegatedTaxDateScopeClauses(rawText);
|
||||
|
|
@ -1216,6 +1241,32 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
: profitMarginBusinessOverviewSignal
|
||||
? "profit_margin_boundary"
|
||||
: "broad_evaluation";
|
||||
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
|
||||
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
|
||||
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(rawAssistantTurnMeaningOrganizationScope)
|
||||
? null
|
||||
: rawAssistantTurnMeaningOrganizationScope;
|
||||
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
|
||||
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
|
||||
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
|
||||
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
|
||||
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization);
|
||||
const organizationMirrorsPredecomposeCounterpartyForPivot = Boolean(sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
|
||||
sameScopedName(predecomposeEntities.counterparty, currentTurnOrganizationScope) ||
|
||||
predecomposeOrganizationMirrorsCounterparty);
|
||||
const normalizedPredecomposeCounterpartyForPivot = organizationMirrorsPredecomposeCounterpartyForPivot
|
||||
? null
|
||||
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
|
||||
const rawExplicitCounterpartyPivotCandidate = rawScopedEntityCandidate ??
|
||||
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate) &&
|
||||
!sameScopedName(candidate, currentTurnOrganizationScope)) ??
|
||||
normalizedPredecomposeCounterpartyForPivot ??
|
||||
null;
|
||||
const businessOverviewCounterpartyValueFlowPivot = Boolean(businessOverviewContinuationSignal &&
|
||||
!rawPrimaryBusinessOverviewSignal &&
|
||||
rawValueFlowPivotTextSignal &&
|
||||
rawExplicitCounterpartyPivotCandidate &&
|
||||
(rawTopicSwitchSignal || rawValueFlowAggregateQuestionSignal));
|
||||
const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal
|
||||
? "inventory_reserve_liquidation_boundary"
|
||||
: debtDueDateBusinessOverviewSignal
|
||||
|
|
@ -1225,8 +1276,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
: profitMarginBusinessOverviewSignal
|
||||
? "profit_margin_boundary"
|
||||
: "broad_business_evaluation";
|
||||
const businessOverviewSignal = rawBusinessOverviewSignal ||
|
||||
seededBusinessOverviewSignal;
|
||||
const businessOverviewSignal = !businessOverviewCounterpartyValueFlowPivot &&
|
||||
(rawBusinessOverviewSignal || seededBusinessOverviewSignal);
|
||||
const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText));
|
||||
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
|
||||
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
|
||||
|
|
@ -1244,15 +1295,6 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
hasSimpleMovementLanePivotSignal(rawText) ||
|
||||
hasMovementEvidenceFollowupSignal(rawText) ||
|
||||
hasPronounMovementEvidenceFollowupSignal(rawText);
|
||||
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
|
||||
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
|
||||
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(rawAssistantTurnMeaningOrganizationScope)
|
||||
? null
|
||||
: rawAssistantTurnMeaningOrganizationScope;
|
||||
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
|
||||
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
|
||||
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
|
||||
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
|
||||
const followupCounterpartyIsMetadataOrganizationScope = Boolean(followupSeed.subjectResolutionOptional &&
|
||||
followupSeed.counterparty &&
|
||||
(followupSeed.metadataScopeHint ||
|
||||
|
|
@ -1288,7 +1330,6 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
const rawOpenScopeValueFlowOrganizationSignal = Boolean(rawValueFlowSignal &&
|
||||
!rawBidirectionalValueFlowSignal &&
|
||||
explicitOrganizationScopeSignal);
|
||||
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization);
|
||||
const organizationMirrorsPredecomposeCounterparty = Boolean((rawBidirectionalValueFlowSignal ||
|
||||
hasValueRankingSignal(rawText) ||
|
||||
rawOpenScopeValueFlowOrganizationSignal ||
|
||||
|
|
@ -1564,14 +1605,25 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
const lifecycleSignal = !businessOverviewSignal && (rawLifecycleSignal || seededDomain === "counterparty_lifecycle");
|
||||
const bidirectionalValueFlowSignal = !businessOverviewSignal &&
|
||||
!lifecycleSignal &&
|
||||
(rawBidirectionalValueFlowSignal || seededAction === "net_value_flow");
|
||||
((businessOverviewCounterpartyValueFlowPivot
|
||||
? rawBidirectionalValueFlowPivotTextSignal
|
||||
: rawBidirectionalValueFlowSignal) ||
|
||||
seededAction === "net_value_flow");
|
||||
const valueFlowSignal = !businessOverviewSignal &&
|
||||
!lifecycleSignal &&
|
||||
!metadataGroundedMovementLaneApplicable &&
|
||||
(rawValueFlowSignal || seededDomain === "counterparty_value");
|
||||
((businessOverviewCounterpartyValueFlowPivot
|
||||
? rawValueFlowPivotTextSignal
|
||||
: rawValueFlowSignal) ||
|
||||
seededDomain === "counterparty_value");
|
||||
const payoutSignal = valueFlowSignal &&
|
||||
!bidirectionalValueFlowSignal &&
|
||||
(rawPayoutSignal || seededAction === "payout");
|
||||
((businessOverviewCounterpartyValueFlowPivot
|
||||
? rawValueFlowPivotTextSignal &&
|
||||
!rawBidirectionalValueFlowPivotTextSignal &&
|
||||
hasPayoutSignal(rawText)
|
||||
: rawPayoutSignal) ||
|
||||
seededAction === "payout");
|
||||
const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable
|
||||
? "metadata lane clarification"
|
||||
: semanticNeedFor({
|
||||
|
|
@ -1579,17 +1631,37 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
? "movements"
|
||||
: businessOverviewSignal
|
||||
? "business_overview"
|
||||
: rawDomain ?? seededDomain,
|
||||
: lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: valueFlowSignal
|
||||
? "counterparty_value"
|
||||
: rawDomain ?? seededDomain,
|
||||
action: explicitVatMovementEvidenceSignal
|
||||
? "list_movements"
|
||||
: businessOverviewSignal
|
||||
? businessOverviewActionFamily
|
||||
: rawAction ?? seededAction,
|
||||
: lifecycleSignal
|
||||
? "activity_duration"
|
||||
: valueFlowSignal
|
||||
? bidirectionalValueFlowSignal
|
||||
? "net_value_flow"
|
||||
: payoutSignal
|
||||
? "payout"
|
||||
: rawAction ?? seededAction ?? "turnover"
|
||||
: rawAction ?? seededAction,
|
||||
unsupported: explicitVatMovementEvidenceSignal
|
||||
? "movement_evidence"
|
||||
: businessOverviewSignal
|
||||
? businessOverviewUnsupportedFamily
|
||||
: unsupported ?? seededUnsupported,
|
||||
: lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: valueFlowSignal
|
||||
? bidirectionalValueFlowSignal
|
||||
? "counterparty_bidirectional_value_flow_or_netting"
|
||||
: payoutSignal
|
||||
? "counterparty_payouts_or_outflow"
|
||||
: seededUnsupported ?? "counterparty_value_or_turnover"
|
||||
: unsupported ?? seededUnsupported,
|
||||
lifecycleSignal,
|
||||
valueFlowSignal,
|
||||
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
|
||||
|
|
@ -1853,16 +1925,16 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined,
|
||||
unsupported_but_understood_family: businessOverviewSignal
|
||||
? businessOverviewUnsupportedFamily
|
||||
: unsupported ??
|
||||
(lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: valueFlowSignal
|
||||
? bidirectionalValueFlowSignal
|
||||
? "counterparty_bidirectional_value_flow_or_netting"
|
||||
: payoutSignal
|
||||
? "counterparty_payouts_or_outflow"
|
||||
: seededUnsupported ?? "counterparty_value_or_turnover"
|
||||
: metadataGroundedMovementLaneApplicable
|
||||
: lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: valueFlowSignal
|
||||
? bidirectionalValueFlowSignal
|
||||
? "counterparty_bidirectional_value_flow_or_netting"
|
||||
: payoutSignal
|
||||
? "counterparty_payouts_or_outflow"
|
||||
: seededUnsupported ?? "counterparty_value_or_turnover"
|
||||
: unsupported ??
|
||||
(metadataGroundedMovementLaneApplicable
|
||||
? "movement_evidence"
|
||||
: metadataGroundedDocumentLaneApplicable
|
||||
? "document_evidence"
|
||||
|
|
@ -2137,6 +2209,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
|
|||
if (businessOverviewSignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_route_candidate");
|
||||
}
|
||||
if (businessOverviewCounterpartyValueFlowPivot) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow");
|
||||
}
|
||||
if (businessOverviewContinuationSignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,57 @@ function detectCounterpartyTurnoverFamily(text) {
|
|||
entity
|
||||
};
|
||||
}
|
||||
function detectScopedCounterpartyEntity(text) {
|
||||
const patterns = [
|
||||
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+(.+?)(?=$|[,.;:!?]|\s+(?:\u0437\u0430|\u043d\u0430|\u0432|\u0432\u043e|\u043a|\u043f\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a|\u043a\u0430\u043a|\u043a\u0430\u043a\u043e\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436\p{L}*|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0432\u0445\u043e\u0434\p{L}*)(?=$|[\s,.;:!?]))/iu,
|
||||
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+([\p{L}\d._-]{2,})(?=$|[\s,.;:!?])/iu
|
||||
];
|
||||
const ignored = new Set([
|
||||
"\u0433\u043e\u0434",
|
||||
"\u0433\u043e\u0434\u0430",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434\u0430",
|
||||
"\u043c\u0435\u0441\u044f\u0446",
|
||||
"\u043c\u0435\u0441\u044f\u0446\u0430",
|
||||
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b",
|
||||
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430",
|
||||
"\u0434\u0435\u043d\u044c\u0433\u0438",
|
||||
"\u043d\u0435\u0442\u0442\u043e",
|
||||
"\u0441\u0430\u043b\u044c\u0434\u043e",
|
||||
"year",
|
||||
"period",
|
||||
"month",
|
||||
"quarter",
|
||||
"net"
|
||||
]);
|
||||
for (const pattern of patterns) {
|
||||
const rawEntity = text.match(pattern)?.[1]?.trim() ?? "";
|
||||
if (!rawEntity) {
|
||||
continue;
|
||||
}
|
||||
const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim();
|
||||
if (entity.length >= 2 && !ignored.has(entity)) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function detectCounterpartyBidirectionalValueFlowFamily(text) {
|
||||
const hasNetCue = /(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|net\s+(?:flow|cash|payment)|cash\s+net)/iu.test(text);
|
||||
const hasIncomingCue = /(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text);
|
||||
const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text);
|
||||
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) {
|
||||
return null;
|
||||
}
|
||||
const entity = detectScopedCounterpartyEntity(text);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
family: "counterparty_bidirectional_value_flow_or_netting",
|
||||
entity
|
||||
};
|
||||
}
|
||||
function hasExplicitCounterpartyValueObject(text) {
|
||||
return /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u0441\u0434\u0435\u043b\u043a|customer|client|counterparty|supplier|vendor|contract|item|product|deal)/iu.test(text);
|
||||
}
|
||||
|
|
@ -279,14 +330,14 @@ function detectBroadBusinessEvaluation(text) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function buildEntityCandidates(counterpartyTurnover) {
|
||||
if (!counterpartyTurnover?.entity) {
|
||||
function buildEntityCandidates(entityFamily) {
|
||||
if (!entityFamily?.entity) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: "counterparty",
|
||||
value: counterpartyTurnover.entity,
|
||||
value: entityFamily.entity,
|
||||
source: "current_turn_loose_entity_tail"
|
||||
}
|
||||
];
|
||||
|
|
@ -299,22 +350,30 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
const effectiveText = normalizeTurnText(effectiveMessage, deps);
|
||||
const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`);
|
||||
const supportedIntent = detectSupportedIntent(joinedText, deps);
|
||||
const counterpartyBidirectionalValueFlow = detectCounterpartyBidirectionalValueFlowFamily(joinedText);
|
||||
const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText);
|
||||
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
|
||||
const broadBusinessEvaluation = selectedObjectInventoryExact ? null : detectBroadBusinessEvaluation(joinedText);
|
||||
const broadBusinessEvaluation = selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family
|
||||
? null
|
||||
: detectBroadBusinessEvaluation(joinedText);
|
||||
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
|
||||
const explicitIntentCandidate = broadBusinessEvaluation?.family
|
||||
? null
|
||||
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
|
||||
const unsupportedFamily = broadBusinessEvaluation?.family
|
||||
? broadBusinessEvaluation.family
|
||||
: !explicitIntentCandidate && counterpartyTurnover?.family
|
||||
? counterpartyTurnover.family
|
||||
: null;
|
||||
: !explicitIntentCandidate && counterpartyBidirectionalValueFlow?.family
|
||||
? counterpartyBidirectionalValueFlow.family
|
||||
: !explicitIntentCandidate && counterpartyTurnover?.family
|
||||
? counterpartyTurnover.family
|
||||
: null;
|
||||
const reasonCodes = [];
|
||||
if (supportedIntent?.reason) {
|
||||
reasonCodes.push(supportedIntent.reason);
|
||||
}
|
||||
if (counterpartyBidirectionalValueFlow?.family) {
|
||||
reasonCodes.push("counterparty_bidirectional_value_flow_current_turn_signal");
|
||||
}
|
||||
if (counterpartyTurnover?.family) {
|
||||
reasonCodes.push("counterparty_turnover_current_turn_signal");
|
||||
}
|
||||
|
|
@ -338,32 +397,39 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
? "inventory"
|
||||
: broadBusinessEvaluation?.family
|
||||
? "business_summary"
|
||||
: explicitIntentCandidate?.includes("counterparty")
|
||||
? "counterparty"
|
||||
: counterpartyTurnover?.family
|
||||
: counterpartyBidirectionalValueFlow?.family
|
||||
? "counterparty_value"
|
||||
: explicitIntentCandidate?.includes("counterparty")
|
||||
? "counterparty"
|
||||
: null;
|
||||
: counterpartyTurnover?.family
|
||||
? "counterparty"
|
||||
: null;
|
||||
const askedActionFamily = explicitIntentCandidate === "receivables_confirmed_as_of_date" ||
|
||||
explicitIntentCandidate === "payables_confirmed_as_of_date" ||
|
||||
explicitIntentCandidate === "inventory_on_hand_as_of_date"
|
||||
? "confirmed_snapshot"
|
||||
: broadBusinessEvaluation?.family
|
||||
? "broad_evaluation"
|
||||
: explicitIntentCandidate === "customer_revenue_and_payments" ||
|
||||
explicitIntentCandidate === "supplier_payouts_profile"
|
||||
? "counterparty_value_or_turnover"
|
||||
: explicitIntentCandidate === "vat_liability_confirmed_for_tax_period"
|
||||
? "confirmed_tax_period"
|
||||
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
|
||||
? "confirmed_snapshot"
|
||||
: explicitIntentCandidate === "vat_payable_forecast"
|
||||
? "forecast"
|
||||
: explicitIntentCandidate === "list_documents_by_counterparty"
|
||||
? "list_documents"
|
||||
: counterpartyTurnover?.family
|
||||
? "counterparty_value_or_turnover"
|
||||
: null;
|
||||
const staleReplayForbidden = Boolean(unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate));
|
||||
: counterpartyBidirectionalValueFlow?.family
|
||||
? "net_value_flow"
|
||||
: explicitIntentCandidate === "customer_revenue_and_payments" ||
|
||||
explicitIntentCandidate === "supplier_payouts_profile"
|
||||
? "counterparty_value_or_turnover"
|
||||
: explicitIntentCandidate === "vat_liability_confirmed_for_tax_period"
|
||||
? "confirmed_tax_period"
|
||||
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
|
||||
? "confirmed_snapshot"
|
||||
: explicitIntentCandidate === "vat_payable_forecast"
|
||||
? "forecast"
|
||||
: explicitIntentCandidate === "list_documents_by_counterparty"
|
||||
? "list_documents"
|
||||
: counterpartyTurnover?.family
|
||||
? "counterparty_value_or_turnover"
|
||||
: null;
|
||||
const staleReplayForbidden = Boolean(unsupportedFamily ||
|
||||
broadBusinessEvaluation?.family ||
|
||||
(counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) ||
|
||||
(counterpartyTurnover?.entity && !explicitIntentCandidate));
|
||||
return {
|
||||
schema_version: "assistant_turn_meaning_v1",
|
||||
raw_message: rawMessage,
|
||||
|
|
@ -373,10 +439,13 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
asked_domain_family: askedDomainFamily,
|
||||
asked_action_family: askedActionFamily,
|
||||
explicit_intent_candidate: explicitIntentCandidate,
|
||||
explicit_entity_candidates: broadBusinessEvaluation?.family ? [] : buildEntityCandidates(counterpartyTurnover),
|
||||
explicit_entity_candidates: broadBusinessEvaluation?.family
|
||||
? []
|
||||
: buildEntityCandidates(counterpartyBidirectionalValueFlow ?? counterpartyTurnover),
|
||||
meaning_confidence: broadBusinessEvaluation?.family
|
||||
? "medium"
|
||||
: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"),
|
||||
: supportedIntent?.confidence ??
|
||||
(counterpartyBidirectionalValueFlow?.family || counterpartyTurnover?.family ? "medium" : "low"),
|
||||
intent_override_strength: explicitIntentCandidate
|
||||
? "explicit_current_turn_intent"
|
||||
: staleReplayForbidden
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ function toRouteHintSummaryV1(normalized) {
|
|||
}
|
||||
const ACCOUNT_HINT_PATTERN = /(?:\b(?:account|acct|schet|счет|сч)\s*[:#]?\s*(?:[1-9][0-9](?:[./-][0-9]{1,2})?)|\b(?:19|20|21|23|25|26|28|29|44|51|60|62|68)\b)/i;
|
||||
const PERIOD_PATTERN = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/i;
|
||||
const BIDIRECTIONAL_VALUE_FLOW_PATTERN = /(?:\b(?:receive(?:d)?|received|get|got|incoming|inflow|paid|payment|payments|outgoing|outflow|net|netto|cash\s*flow)\b|получ(?:ить|ил[аи]?|ено|аем|или)|поступ(?:ил[аи]?|ление|ления)|заплат(?:ить|ил[аи]?|или)|оплат(?:ить|ил[аи]?|ы|или)|входящ(?:ий|ие|их)|исходящ(?:ий|ие|их)|нетто|сальдо)/iu;
|
||||
const COUNTERPARTY_SCOPE_PATTERN = /(?:\b(?:counterparty|supplier|customer|vendor|client|bank)\b|контрагент|поставщик|покупател|клиент|заказчик|банк|сбербанк|по\s+(?:ип|ооо|пао|зао|оао|группа)\b)/iu;
|
||||
const SYMPTOM_MARKER_PATTERN = /(?:\bsymptom\b|\banomaly\b|\bproblem\b|\bissue\b|\btail\b|\bhanging\b|\bblocked\b|\bincomplete\b|remains?\s+open|not\s+(?:confirmed|observed|resolved|closed)|не\s+(?:подтвержден|закрыт|наблюдается)|хвост|сбой|проблем)/i;
|
||||
const LIFECYCLE_MARKER_PATTERN = /(?:\blifecycle\b|\bchain\b|\btransition\b|\bstep\b|\btrace\b|цепоч|этап|переход|связк|где\s+разрыв)/i;
|
||||
const CHAIN_BREAK_PATTERN = /(?:\bbreak\b|\bbroken\b|\bgap\b|missing\s+(?:transition|step|link)|chain\s+break|разрыв|обрыв|нет\s+переход|не\s+дошл|не\s+наблюд)/i;
|
||||
|
|
@ -54,6 +56,13 @@ exports.ROUTE_DISCIPLINE_RULE_TABLE = [
|
|||
forbidden_fallback: ["store_canonical", "hybrid_store_plus_live"],
|
||||
description: "Ranking and period summary queries require analytical batch path."
|
||||
},
|
||||
{
|
||||
query_class: "bidirectional_value_flow",
|
||||
required_route: "hybrid_store_plus_live",
|
||||
allowed_fallback: ["no_route"],
|
||||
forbidden_fallback: ["store_canonical"],
|
||||
description: "Scoped bidirectional value-flow questions require hybrid evidence path."
|
||||
},
|
||||
{
|
||||
query_class: "symptom_first",
|
||||
required_route: "hybrid_store_plus_live",
|
||||
|
|
@ -155,6 +164,17 @@ function hasAmbiguitySignal(fragment, lowerText) {
|
|||
function hasAccountOrPeriodAnchor(fragment, lowerText) {
|
||||
return fragment.account_hints.length > 0 || ACCOUNT_HINT_PATTERN.test(lowerText) || PERIOD_PATTERN.test(lowerText);
|
||||
}
|
||||
function hasBidirectionalValueFlowSignal(fragment, lowerText) {
|
||||
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
|
||||
return false;
|
||||
}
|
||||
return BIDIRECTIONAL_VALUE_FLOW_PATTERN.test(lowerText);
|
||||
}
|
||||
function hasCounterpartyScopeSignal(fragment, lowerText) {
|
||||
return (COUNTERPARTY_SCOPE_PATTERN.test(lowerText) ||
|
||||
fragment.entity_hints.some((hint) => hint.trim().length > 0) ||
|
||||
fragment.candidate_labels.includes("cross_entity"));
|
||||
}
|
||||
function resolveRouteClass(fragment) {
|
||||
const lowerText = mergedFragmentText(fragment);
|
||||
const symptomSignal = hasSymptomSignal(fragment, lowerText);
|
||||
|
|
@ -164,12 +184,17 @@ function resolveRouteClass(fragment) {
|
|||
const causalSignal = hasCausalSignal(lowerText);
|
||||
const ambiguitySignal = hasAmbiguitySignal(fragment, lowerText);
|
||||
const accountOrPeriodAnchor = hasAccountOrPeriodAnchor(fragment, lowerText);
|
||||
const bidirectionalValueFlowSignal = hasBidirectionalValueFlowSignal(fragment, lowerText);
|
||||
const counterpartyScopeSignal = hasCounterpartyScopeSignal(fragment, lowerText);
|
||||
if (fragment.flags.asks_for_exact_object_trace) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("exact_object_trace");
|
||||
}
|
||||
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("ranking_or_period_summary");
|
||||
}
|
||||
if (bidirectionalValueFlowSignal && counterpartyScopeSignal) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("bidirectional_value_flow");
|
||||
}
|
||||
if (ambiguitySignal && (symptomSignal || lifecycleSignal || chainBreakSignal || periodImpactSignal || causalSignal)) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("mixed_ambiguity");
|
||||
}
|
||||
|
|
@ -205,7 +230,8 @@ function shouldPromoteFromNoRoute(fragment, rule) {
|
|||
hasLifecycleSignal(fragment, lowerText) ||
|
||||
hasChainBreakSignal(lowerText) ||
|
||||
hasPeriodImpactSignal(lowerText) ||
|
||||
hasCausalSignal(lowerText);
|
||||
hasCausalSignal(lowerText) ||
|
||||
(hasBidirectionalValueFlowSignal(fragment, lowerText) && hasCounterpartyScopeSignal(fragment, lowerText));
|
||||
const hasAnchor = hasAccountOrPeriodAnchor(fragment, lowerText) ||
|
||||
fragment.candidate_labels.includes("cross_entity") ||
|
||||
DOMAIN_LEXICAL_ANCHOR_PATTERN.test(lowerText);
|
||||
|
|
|
|||
|
|
@ -1287,7 +1287,8 @@ function loadSessionDialog(runId: string, caseId: string): {
|
|||
text: toStringSafe(item.text) ?? "",
|
||||
created_at: toStringSafe(item.created_at),
|
||||
trace_id: toStringSafe(item.trace_id),
|
||||
reply_type: toStringSafe(item.reply_type)
|
||||
reply_type: toStringSafe(item.reply_type),
|
||||
debug: item.debug ?? null
|
||||
}));
|
||||
|
||||
const turns = toArray(record.turns)
|
||||
|
|
@ -1364,7 +1365,8 @@ function buildFallbackDialog(run: IndexedRun, caseId: string): {
|
|||
text: userText,
|
||||
created_at: null,
|
||||
trace_id: null,
|
||||
reply_type: null
|
||||
reply_type: null,
|
||||
debug: null
|
||||
},
|
||||
{
|
||||
message_id: null,
|
||||
|
|
@ -1372,7 +1374,8 @@ function buildFallbackDialog(run: IndexedRun, caseId: string): {
|
|||
text: assistantSummaryParts.join("\n"),
|
||||
created_at: null,
|
||||
trace_id: toStringSafe(targetCase.trace_id),
|
||||
reply_type: toStringSafe(targetCase.reply_type)
|
||||
reply_type: toStringSafe(targetCase.reply_type),
|
||||
debug: null
|
||||
}
|
||||
],
|
||||
decomposition: [],
|
||||
|
|
|
|||
|
|
@ -524,6 +524,73 @@ function bankOperationEvidenceLine(
|
|||
return `Основание 1С: ${parts.join("; ")}.`;
|
||||
}
|
||||
|
||||
type BankOperationSemanticBucket =
|
||||
| "commission"
|
||||
| "deposit_or_credit"
|
||||
| "tax_or_budget"
|
||||
| "transfer_or_return"
|
||||
| "other";
|
||||
|
||||
function classifyBankOperationSemanticBucket(row: ComposeStageRow): BankOperationSemanticBucket {
|
||||
const text = [
|
||||
row.registrator,
|
||||
row.operation_kind,
|
||||
row.payment_purpose,
|
||||
row.contract,
|
||||
row.comment
|
||||
]
|
||||
.map((item) => String(item ?? "").toLowerCase())
|
||||
.join(" ");
|
||||
|
||||
if (/(?:комисс|тариф|эквайр|обслуживан)/iu.test(text)) {
|
||||
return "commission";
|
||||
}
|
||||
if (/(?:депозит|кредит|займ|овердрафт|процент|ссуд)/iu.test(text)) {
|
||||
return "deposit_or_credit";
|
||||
}
|
||||
if (/(?:налог|ндс|взнос|бюджет|фнс|пфр|страхов)/iu.test(text)) {
|
||||
return "tax_or_budget";
|
||||
}
|
||||
if (/(?:возврат|перевод|перечислен|переброс|пополн|инкасс|перенос)/iu.test(text)) {
|
||||
return "transfer_or_return";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function bankOperationSemanticBucketLabel(bucket: BankOperationSemanticBucket): string {
|
||||
if (bucket === "commission") {
|
||||
return "комиссии и банковое обслуживание";
|
||||
}
|
||||
if (bucket === "deposit_or_credit") {
|
||||
return "депозиты, кредиты или проценты";
|
||||
}
|
||||
if (bucket === "tax_or_budget") {
|
||||
return "налоги и бюджетные платежи";
|
||||
}
|
||||
if (bucket === "transfer_or_return") {
|
||||
return "переводы, возвраты или перебросы";
|
||||
}
|
||||
return "прочие банковские операции";
|
||||
}
|
||||
|
||||
function summarizeBankOperationSemantics(rows: ComposeStageRow[]): string | null {
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const counts = new Map<BankOperationSemanticBucket, number>();
|
||||
for (const row of rows) {
|
||||
const bucket = classifyBankOperationSemanticBucket(row);
|
||||
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
|
||||
}
|
||||
const ranked = Array.from(counts.entries())
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.slice(0, 3);
|
||||
if (ranked.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const parts = ranked.map(([bucket, count]) => `${bankOperationSemanticBucketLabel(bucket)} — ${count}`);
|
||||
return `По смыслу это скорее финансовый/банковский контур: ${parts.join("; ")}.`;
|
||||
}
|
||||
function bankRoleBoundaryLine(userMessage: string | null | undefined, rows: ComposeStageRow[]): string | null {
|
||||
const incomingBoundary = hasBankIncomingRoleBoundaryQuestion(userMessage);
|
||||
const outgoingBoundary = hasBankOutgoingRoleBoundaryQuestion(userMessage);
|
||||
|
|
@ -5013,13 +5080,34 @@ function composeFactualReplyBody(
|
|||
);
|
||||
const counterparty = resolvePreferredCounterpartyDisplayLabel(options.counterpartyHint, rowCounterparties);
|
||||
const roleBoundary = bankRoleBoundaryLine(options.userMessage, rows);
|
||||
const visibleRows = rows.slice(0, Math.min(rows.length, 5));
|
||||
const visibleRows = [...rows]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(right.amount ?? 0) - Math.abs(left.amount ?? 0) ||
|
||||
(String(right.period ?? "").localeCompare(String(left.period ?? ""), "ru"))
|
||||
)
|
||||
.slice(0, Math.min(rows.length, 5));
|
||||
const semanticSummary = summarizeBankOperationSemantics(rows);
|
||||
const compactEvidenceRows = visibleRows.map((row, index) => {
|
||||
const direction = bankOperationDirectionLabel(bankOperationDirection(row));
|
||||
const amount = formatMoneyRub(row.amount ?? 0);
|
||||
const period = row.period ? formatDateRu(row.period) : "дата не указана";
|
||||
const operationKind = String(row.operation_kind ?? "").trim();
|
||||
const paymentPurpose = String(row.payment_purpose ?? "").trim();
|
||||
const detail = operationKind || paymentPurpose
|
||||
? ` | ${[operationKind, paymentPurpose].filter(Boolean).join("; ")}`
|
||||
: "";
|
||||
return `${index + 1}. ${period} | ${direction} | ${amount}${detail}`;
|
||||
});
|
||||
const lines = [
|
||||
`Коротко: найдено банковских операций${counterparty ? ` по ${counterparty}` : " по контрагенту"} — ${rows.length}.`,
|
||||
summarizeBankOperationDirections(rows),
|
||||
roleBoundary ?? "Показываю подтвержденные банковские операции из текущего среза.",
|
||||
bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)),
|
||||
...formatTopRows(visibleRows, visibleRows.length)
|
||||
...(semanticSummary ? [semanticSummary] : []),
|
||||
"Примеры строк 1С:",
|
||||
...compactEvidenceRows,
|
||||
"Следующий шаг: могу отдельно разложить назначения платежа, договоры или отделить банковский контур от клиентского/поставщицкого."
|
||||
];
|
||||
if (rows.length > visibleRows.length) {
|
||||
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length}; полный список остается в подтвержденном срезе.`);
|
||||
|
|
|
|||
|
|
@ -120,6 +120,26 @@ function findFocusedCounterpartyValuePoint(
|
|||
return profileRows.length === 1 ? profileRows[0] : null;
|
||||
}
|
||||
|
||||
function hasProfitAmbiguityCue(normalizedQuestion: string): boolean {
|
||||
return /(?:заработ|прибыл|прибыль|доход|выручк)/iu.test(normalizedQuestion);
|
||||
}
|
||||
|
||||
function buildCashflowBoundaryLine(isSupplier: boolean): string {
|
||||
return isSupplier
|
||||
? "Граница ответа: это подтвержденный денежный поток по поставщику, а не итоговая задолженность."
|
||||
: "Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.";
|
||||
}
|
||||
|
||||
function buildCashflowNextStepLine(isSupplier: boolean, normalizedQuestion: string): string | null {
|
||||
if (isSupplier) {
|
||||
return "Следующий шаг: могу отдельно показать остаток долга, просрочку или расшифровку по документам.";
|
||||
}
|
||||
if (hasProfitAmbiguityCue(normalizedQuestion)) {
|
||||
return "Следующий шаг: могу отдельно проверить чистую прибыль по закрытию 90/91/99.";
|
||||
}
|
||||
return "Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.";
|
||||
}
|
||||
|
||||
export function composeCounterpartyAnalyticsReply(
|
||||
intent: AddressIntent,
|
||||
rows: ComposeStageRow[],
|
||||
|
|
@ -546,6 +566,8 @@ export function composeCounterpartyAnalyticsReply(
|
|||
const semanticSingleBestCounterparty =
|
||||
focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList;
|
||||
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
|
||||
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
|
||||
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
|
||||
|
||||
const byCounterparty = new Map<string, CounterpartyValuePoint>();
|
||||
const byYear = new Map<number, CounterpartyYearPoint>();
|
||||
|
|
@ -655,10 +677,12 @@ export function composeCounterpartyAnalyticsReply(
|
|||
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
|
||||
: "за доступное время";
|
||||
const directAnswerLine = isSupplier
|
||||
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.`
|
||||
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`;
|
||||
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям.`
|
||||
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
cashflowBoundaryLine,
|
||||
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Контрагент в выборке: ${focusedCounterparty.name}.`,
|
||||
|
|
@ -678,11 +702,10 @@ export function composeCounterpartyAnalyticsReply(
|
|||
options.periodFrom && options.periodTo
|
||||
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
|
||||
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
|
||||
const directAnswerLine = isSupplier
|
||||
? periodLine
|
||||
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
|
||||
const summaryLines = [
|
||||
directAnswerLine,
|
||||
periodLine,
|
||||
cashflowBoundaryLine,
|
||||
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
|
||||
"",
|
||||
"Подтверждение:",
|
||||
`- Операций в выборке: ${totalOperations}.`,
|
||||
|
|
@ -709,11 +732,17 @@ export function composeCounterpartyAnalyticsReply(
|
|||
const strongestYear = visible[0];
|
||||
const directAnswerLine = isSupplier
|
||||
? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`
|
||||
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`;
|
||||
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`;
|
||||
const heading = isSupplier
|
||||
? `Топ-${visible.length} лет по сумме выплат:`
|
||||
: `Топ-${visible.length} лет по сумме поступлений:`;
|
||||
lines.unshift(heading);
|
||||
if (!isSupplier) {
|
||||
lines.unshift(cashflowBoundaryLine);
|
||||
if (cashflowNextStepLine) {
|
||||
lines.unshift(cashflowNextStepLine);
|
||||
}
|
||||
}
|
||||
lines.unshift(directAnswerLine);
|
||||
lines.push(
|
||||
...visible.map(
|
||||
|
|
@ -829,11 +858,17 @@ export function composeCounterpartyAnalyticsReply(
|
|||
const directAnswerLine = singleCandidateOnly
|
||||
? isSupplier
|
||||
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
|
||||
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
|
||||
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
|
||||
: isSupplier
|
||||
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
|
||||
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
|
||||
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
|
||||
lines.unshift(directAnswerLine);
|
||||
if (!isSupplier) {
|
||||
lines.splice(1, 0, cashflowBoundaryLine);
|
||||
if (cashflowNextStepLine) {
|
||||
lines.splice(2, 0, cashflowNextStepLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
...visible.map((item, index) => {
|
||||
|
|
|
|||
|
|
@ -175,11 +175,10 @@ export function composeInventoryReply(
|
|||
const uniqueWarehouses = deps.uniqueStrings(
|
||||
positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)
|
||||
);
|
||||
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
|
||||
const directAnswerLine =
|
||||
positions.length > 0
|
||||
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
|
||||
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
|
||||
const lines: string[] = [directAnswerLine];
|
||||
|
||||
|
|
@ -213,11 +212,14 @@ export function composeInventoryReply(
|
|||
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
|
||||
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
|
||||
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
|
||||
`Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`
|
||||
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
|
||||
]);
|
||||
if (rows.length !== positions.length) {
|
||||
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
|
||||
}
|
||||
if (positions.length > 0) {
|
||||
lines.push("- Следующий шаг: могу раскрыть полный список, разложить остатки по складам или сравнить с другой датой.");
|
||||
}
|
||||
|
||||
return positions.length > 0
|
||||
? buildFactualListReply(lines, buildConfirmedBalanceSemantics("strong"))
|
||||
|
|
|
|||
|
|
@ -1128,6 +1128,40 @@ function buildCompactBusinessOverviewReply(
|
|||
}
|
||||
|
||||
if (rankingNeed) {
|
||||
const explicitPeriodRankingOverview =
|
||||
period &&
|
||||
!/(?:все\s+доступное|все\s+время|all\s+time)/iu.test(period) &&
|
||||
(incomingAmount || outgoingAmount || netAmount);
|
||||
if (explicitPeriodRankingOverview) {
|
||||
lines.push(
|
||||
`Коротко: ${organizationPrefix}${period} денежная картина подтверждена по найденным строкам 1С.`
|
||||
);
|
||||
lines.push(
|
||||
`Деньги: входящие ${incomingAmount ?? "0 руб."}, исходящие ${outgoingAmount ?? "0 руб."}, расчетное операционное нетто ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
|
||||
);
|
||||
if (customerName && customerAmount) {
|
||||
lines.push(
|
||||
topCustomerLooksFinancial
|
||||
? `Топ входящих: 1. ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}. Это финансовый/банковский контур, не считаю его клиентской выручкой без назначения платежа.${nonFinancialCustomer ? ` 2. Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
|
||||
: `Крупнейший входящий контрагент: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`
|
||||
);
|
||||
}
|
||||
if (topSupplier) {
|
||||
lines.push(
|
||||
topSupplierLooksFinancial
|
||||
? `Топ исходящих: 1. ${topSupplier}. Это финансовый/банковский контур, не считаю его обычным поставщиком без назначения платежа и договора.${nonFinancialSupplier ? ` 2. Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}`
|
||||
: `Крупнейший получатель исходящих денег: ${topSupplier}.`
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Вывод: по движению денег период ${netDirection}; это не чистая прибыль и не бухгалтерский финрезультат.`
|
||||
);
|
||||
if (requestedFinancialBoundaryLine) {
|
||||
lines.push(requestedFinancialBoundaryLine);
|
||||
}
|
||||
lines.push("Следующий шаг: могу отдельно посчитать чистую прибыль через закрытие 90/91/99 или разложить этот период по контрагентам.");
|
||||
return joinBusinessReplyLines(lines);
|
||||
}
|
||||
const incomingLeader = strongestIncomingYear(overview);
|
||||
const canRankYearlyNet = !limitLine;
|
||||
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
|
||||
|
|
|
|||
|
|
@ -1600,15 +1600,48 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
const rawEffectiveText = toNonEmptyString(input.effectiveMessage);
|
||||
const repairedUserText = rawUserText ? repairAddressMojibakeText(rawUserText) : null;
|
||||
const repairedEffectiveText = rawEffectiveText ? repairAddressMojibakeText(rawEffectiveText) : null;
|
||||
const rawUserSignalSourceText = repairedUserText ?? rawUserText ?? "";
|
||||
const rawSignalSourceText = `${repairedUserText ?? rawUserText ?? ""} ${repairedEffectiveText ?? rawEffectiveText ?? ""}`.trim();
|
||||
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
|
||||
const rawUserEntitySourceText = rawUserSignalSourceText || rawEntitySourceText;
|
||||
const rawUserTextOnly = compactLower(rawUserSignalSourceText);
|
||||
const rawAssistantEntityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates);
|
||||
const rawUserPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawUserTextOnly);
|
||||
const rawUserLifecyclePivotTextSignal =
|
||||
!rawUserPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawUserTextOnly);
|
||||
const rawUserBidirectionalValueFlowPivotTextSignal =
|
||||
!rawUserPrimaryBusinessOverviewSignal &&
|
||||
!rawUserLifecyclePivotTextSignal &&
|
||||
hasBidirectionalValueFlowSignal(rawUserTextOnly);
|
||||
const rawUserScopedEntityCandidate = rawUserSignalSourceText
|
||||
? rawScopedEntityCandidateFromText(rawUserEntitySourceText)
|
||||
: null;
|
||||
const rawUserCounterpartyBidirectionalOverride = Boolean(
|
||||
rawUserBidirectionalValueFlowPivotTextSignal &&
|
||||
(rawUserScopedEntityCandidate ||
|
||||
predecomposeEntities.counterparty ||
|
||||
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate)))
|
||||
);
|
||||
const rawText = compactLower(rawSignalSourceText);
|
||||
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(
|
||||
repairedUserText ?? rawUserText ?? ""
|
||||
);
|
||||
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
|
||||
const rawPrimaryBusinessOverviewSignal =
|
||||
hasBusinessOverviewSignal(rawText) && !rawUserCounterpartyBidirectionalOverride;
|
||||
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
|
||||
const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText);
|
||||
const rawLifecyclePivotTextSignal =
|
||||
!rawPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawText);
|
||||
const rawBidirectionalValueFlowPivotTextSignal =
|
||||
!rawPrimaryBusinessOverviewSignal &&
|
||||
!rawLifecyclePivotTextSignal &&
|
||||
hasBidirectionalValueFlowSignal(rawText);
|
||||
const rawValueFlowPivotTextSignal =
|
||||
!rawPrimaryBusinessOverviewSignal &&
|
||||
!rawLifecyclePivotTextSignal &&
|
||||
(hasValueFlowSignal(rawText) ||
|
||||
hasValueRankingSignal(rawText) ||
|
||||
rawBidirectionalValueFlowPivotTextSignal);
|
||||
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(
|
||||
explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal
|
||||
);
|
||||
|
|
@ -1634,7 +1667,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
!rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
|
||||
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
|
||||
const rawValueFlowAggregateQuestionSignal =
|
||||
rawValueFlowSignal && hasValueFlowAggregateQuestionSignal(rawText);
|
||||
(rawValueFlowSignal || rawValueFlowPivotTextSignal) && hasValueFlowAggregateQuestionSignal(rawText);
|
||||
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
|
||||
const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText);
|
||||
const dateScopeSignalText = stripNegatedTaxDateScopeClauses(rawText);
|
||||
|
|
@ -1702,6 +1735,47 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
: profitMarginBusinessOverviewSignal
|
||||
? "profit_margin_boundary"
|
||||
: "broad_evaluation";
|
||||
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
|
||||
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
|
||||
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(
|
||||
rawAssistantTurnMeaningOrganizationScope
|
||||
)
|
||||
? null
|
||||
: rawAssistantTurnMeaningOrganizationScope;
|
||||
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
|
||||
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
|
||||
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
|
||||
const currentTurnOrganizationScope =
|
||||
currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
|
||||
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(
|
||||
predecomposeEntities.counterparty,
|
||||
predecomposeEntities.organization
|
||||
);
|
||||
const organizationMirrorsPredecomposeCounterpartyForPivot = Boolean(
|
||||
sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
|
||||
sameScopedName(predecomposeEntities.counterparty, currentTurnOrganizationScope) ||
|
||||
predecomposeOrganizationMirrorsCounterparty
|
||||
);
|
||||
const normalizedPredecomposeCounterpartyForPivot =
|
||||
organizationMirrorsPredecomposeCounterpartyForPivot
|
||||
? null
|
||||
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
|
||||
const rawExplicitCounterpartyPivotCandidate =
|
||||
rawScopedEntityCandidate ??
|
||||
rawAssistantEntityCandidates.find(
|
||||
(candidate) =>
|
||||
!isInvalidEntityCandidate(candidate) &&
|
||||
!sameScopedName(candidate, currentTurnOrganizationScope)
|
||||
) ??
|
||||
normalizedPredecomposeCounterpartyForPivot ??
|
||||
null;
|
||||
const businessOverviewCounterpartyValueFlowPivot = Boolean(
|
||||
businessOverviewContinuationSignal &&
|
||||
!rawPrimaryBusinessOverviewSignal &&
|
||||
rawValueFlowPivotTextSignal &&
|
||||
rawExplicitCounterpartyPivotCandidate &&
|
||||
(rawTopicSwitchSignal || rawValueFlowAggregateQuestionSignal)
|
||||
);
|
||||
const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal
|
||||
? "inventory_reserve_liquidation_boundary"
|
||||
: debtDueDateBusinessOverviewSignal
|
||||
|
|
@ -1712,8 +1786,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
? "profit_margin_boundary"
|
||||
: "broad_business_evaluation";
|
||||
const businessOverviewSignal =
|
||||
rawBusinessOverviewSignal ||
|
||||
seededBusinessOverviewSignal;
|
||||
!businessOverviewCounterpartyValueFlowPivot &&
|
||||
(rawBusinessOverviewSignal || seededBusinessOverviewSignal);
|
||||
const businessOverviewSeparateCounterpartySignal = Boolean(
|
||||
businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText)
|
||||
);
|
||||
|
|
@ -1735,18 +1809,6 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
hasSimpleMovementLanePivotSignal(rawText) ||
|
||||
hasMovementEvidenceFollowupSignal(rawText) ||
|
||||
hasPronounMovementEvidenceFollowupSignal(rawText);
|
||||
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
|
||||
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
|
||||
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(
|
||||
rawAssistantTurnMeaningOrganizationScope
|
||||
)
|
||||
? null
|
||||
: rawAssistantTurnMeaningOrganizationScope;
|
||||
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
|
||||
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
|
||||
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
|
||||
const currentTurnOrganizationScope =
|
||||
currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
|
||||
const followupCounterpartyIsMetadataOrganizationScope = Boolean(
|
||||
followupSeed.subjectResolutionOptional &&
|
||||
followupSeed.counterparty &&
|
||||
|
|
@ -1792,10 +1854,6 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
!rawBidirectionalValueFlowSignal &&
|
||||
explicitOrganizationScopeSignal
|
||||
);
|
||||
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(
|
||||
predecomposeEntities.counterparty,
|
||||
predecomposeEntities.organization
|
||||
);
|
||||
const organizationMirrorsPredecomposeCounterparty = Boolean(
|
||||
(rawBidirectionalValueFlowSignal ||
|
||||
hasValueRankingSignal(rawText) ||
|
||||
|
|
@ -2130,16 +2188,27 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
const bidirectionalValueFlowSignal =
|
||||
!businessOverviewSignal &&
|
||||
!lifecycleSignal &&
|
||||
(rawBidirectionalValueFlowSignal || seededAction === "net_value_flow");
|
||||
((businessOverviewCounterpartyValueFlowPivot
|
||||
? rawBidirectionalValueFlowPivotTextSignal
|
||||
: rawBidirectionalValueFlowSignal) ||
|
||||
seededAction === "net_value_flow");
|
||||
const valueFlowSignal =
|
||||
!businessOverviewSignal &&
|
||||
!lifecycleSignal &&
|
||||
!metadataGroundedMovementLaneApplicable &&
|
||||
(rawValueFlowSignal || seededDomain === "counterparty_value");
|
||||
((businessOverviewCounterpartyValueFlowPivot
|
||||
? rawValueFlowPivotTextSignal
|
||||
: rawValueFlowSignal) ||
|
||||
seededDomain === "counterparty_value");
|
||||
const payoutSignal =
|
||||
valueFlowSignal &&
|
||||
!bidirectionalValueFlowSignal &&
|
||||
(rawPayoutSignal || seededAction === "payout");
|
||||
((businessOverviewCounterpartyValueFlowPivot
|
||||
? rawValueFlowPivotTextSignal &&
|
||||
!rawBidirectionalValueFlowPivotTextSignal &&
|
||||
hasPayoutSignal(rawText)
|
||||
: rawPayoutSignal) ||
|
||||
seededAction === "payout");
|
||||
const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable
|
||||
? "metadata lane clarification"
|
||||
: semanticNeedFor({
|
||||
|
|
@ -2147,17 +2216,37 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
? "movements"
|
||||
: businessOverviewSignal
|
||||
? "business_overview"
|
||||
: lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: valueFlowSignal
|
||||
? "counterparty_value"
|
||||
: rawDomain ?? seededDomain,
|
||||
action: explicitVatMovementEvidenceSignal
|
||||
? "list_movements"
|
||||
: businessOverviewSignal
|
||||
? businessOverviewActionFamily
|
||||
: lifecycleSignal
|
||||
? "activity_duration"
|
||||
: valueFlowSignal
|
||||
? bidirectionalValueFlowSignal
|
||||
? "net_value_flow"
|
||||
: payoutSignal
|
||||
? "payout"
|
||||
: rawAction ?? seededAction ?? "turnover"
|
||||
: rawAction ?? seededAction,
|
||||
unsupported: explicitVatMovementEvidenceSignal
|
||||
? "movement_evidence"
|
||||
: businessOverviewSignal
|
||||
? businessOverviewUnsupportedFamily
|
||||
: unsupported ?? seededUnsupported,
|
||||
: lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: valueFlowSignal
|
||||
? bidirectionalValueFlowSignal
|
||||
? "counterparty_bidirectional_value_flow_or_netting"
|
||||
: payoutSignal
|
||||
? "counterparty_payouts_or_outflow"
|
||||
: seededUnsupported ?? "counterparty_value_or_turnover"
|
||||
: unsupported ?? seededUnsupported,
|
||||
lifecycleSignal,
|
||||
valueFlowSignal,
|
||||
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
|
||||
|
|
@ -2469,30 +2558,30 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
unsupported_but_understood_family:
|
||||
businessOverviewSignal
|
||||
? businessOverviewUnsupportedFamily
|
||||
: unsupported ??
|
||||
(lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: lifecycleSignal
|
||||
? "counterparty_lifecycle"
|
||||
: valueFlowSignal
|
||||
? bidirectionalValueFlowSignal
|
||||
? "counterparty_bidirectional_value_flow_or_netting"
|
||||
: payoutSignal
|
||||
? "counterparty_payouts_or_outflow"
|
||||
: seededUnsupported ?? "counterparty_value_or_turnover"
|
||||
: metadataGroundedMovementLaneApplicable
|
||||
? "movement_evidence"
|
||||
: metadataGroundedDocumentLaneApplicable
|
||||
? "document_evidence"
|
||||
: explicitVatMovementEvidenceSignal
|
||||
? "movement_evidence"
|
||||
: metadataAmbiguityLaneClarificationApplicable
|
||||
? "metadata_lane_choice_clarification"
|
||||
: entityResolutionSignal
|
||||
? "entity_resolution"
|
||||
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
|
||||
? "1c_metadata_surface"
|
||||
: followupDiscoverySeedApplicable
|
||||
? seededUnsupported
|
||||
: null),
|
||||
: unsupported ??
|
||||
(metadataGroundedMovementLaneApplicable
|
||||
? "movement_evidence"
|
||||
: metadataGroundedDocumentLaneApplicable
|
||||
? "document_evidence"
|
||||
: explicitVatMovementEvidenceSignal
|
||||
? "movement_evidence"
|
||||
: metadataAmbiguityLaneClarificationApplicable
|
||||
? "metadata_lane_choice_clarification"
|
||||
: entityResolutionSignal
|
||||
? "entity_resolution"
|
||||
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
|
||||
? "1c_metadata_surface"
|
||||
: followupDiscoverySeedApplicable
|
||||
? seededUnsupported
|
||||
: null),
|
||||
stale_replay_forbidden: Boolean(
|
||||
assistantTurnMeaning?.stale_replay_forbidden ||
|
||||
businessOverviewSignal ||
|
||||
|
|
@ -2763,6 +2852,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
|
|||
if (businessOverviewSignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_route_candidate");
|
||||
}
|
||||
if (businessOverviewCounterpartyValueFlowPivot) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow");
|
||||
}
|
||||
if (businessOverviewContinuationSignal) {
|
||||
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,62 @@ function detectCounterpartyTurnoverFamily(text) {
|
|||
};
|
||||
}
|
||||
|
||||
function detectScopedCounterpartyEntity(text) {
|
||||
const patterns = [
|
||||
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+(.+?)(?=$|[,.;:!?]|\s+(?:\u0437\u0430|\u043d\u0430|\u0432|\u0432\u043e|\u043a|\u043f\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a|\u043a\u0430\u043a|\u043a\u0430\u043a\u043e\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436\p{L}*|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0432\u0445\u043e\u0434\p{L}*)(?=$|[\s,.;:!?]))/iu,
|
||||
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+([\p{L}\d._-]{2,})(?=$|[\s,.;:!?])/iu
|
||||
];
|
||||
const ignored = new Set([
|
||||
"\u0433\u043e\u0434",
|
||||
"\u0433\u043e\u0434\u0430",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434",
|
||||
"\u043f\u0435\u0440\u0438\u043e\u0434\u0430",
|
||||
"\u043c\u0435\u0441\u044f\u0446",
|
||||
"\u043c\u0435\u0441\u044f\u0446\u0430",
|
||||
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b",
|
||||
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430",
|
||||
"\u0434\u0435\u043d\u044c\u0433\u0438",
|
||||
"\u043d\u0435\u0442\u0442\u043e",
|
||||
"\u0441\u0430\u043b\u044c\u0434\u043e",
|
||||
"year",
|
||||
"period",
|
||||
"month",
|
||||
"quarter",
|
||||
"net"
|
||||
]);
|
||||
for (const pattern of patterns) {
|
||||
const rawEntity = text.match(pattern)?.[1]?.trim() ?? "";
|
||||
if (!rawEntity) {
|
||||
continue;
|
||||
}
|
||||
const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim();
|
||||
if (entity.length >= 2 && !ignored.has(entity)) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectCounterpartyBidirectionalValueFlowFamily(text) {
|
||||
const hasNetCue =
|
||||
/(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|net\s+(?:flow|cash|payment)|cash\s+net)/iu.test(text);
|
||||
const hasIncomingCue =
|
||||
/(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text);
|
||||
const hasOutgoingCue =
|
||||
/(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text);
|
||||
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) {
|
||||
return null;
|
||||
}
|
||||
const entity = detectScopedCounterpartyEntity(text);
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
family: "counterparty_bidirectional_value_flow_or_netting",
|
||||
entity
|
||||
};
|
||||
}
|
||||
|
||||
function hasExplicitCounterpartyValueObject(text) {
|
||||
return /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u0441\u0434\u0435\u043b\u043a|customer|client|counterparty|supplier|vendor|contract|item|product|deal)/iu.test(
|
||||
text
|
||||
|
|
@ -379,14 +435,14 @@ function detectBroadBusinessEvaluation(text) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function buildEntityCandidates(counterpartyTurnover) {
|
||||
if (!counterpartyTurnover?.entity) {
|
||||
function buildEntityCandidates(entityFamily) {
|
||||
if (!entityFamily?.entity) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: "counterparty",
|
||||
value: counterpartyTurnover.entity,
|
||||
value: entityFamily.entity,
|
||||
source: "current_turn_loose_entity_tail"
|
||||
}
|
||||
];
|
||||
|
|
@ -400,9 +456,13 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
const effectiveText = normalizeTurnText(effectiveMessage, deps);
|
||||
const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`);
|
||||
const supportedIntent = detectSupportedIntent(joinedText, deps);
|
||||
const counterpartyBidirectionalValueFlow = detectCounterpartyBidirectionalValueFlowFamily(joinedText);
|
||||
const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText);
|
||||
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
|
||||
const broadBusinessEvaluation = selectedObjectInventoryExact ? null : detectBroadBusinessEvaluation(joinedText);
|
||||
const broadBusinessEvaluation =
|
||||
selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family
|
||||
? null
|
||||
: detectBroadBusinessEvaluation(joinedText);
|
||||
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
|
||||
const explicitIntentCandidate =
|
||||
broadBusinessEvaluation?.family
|
||||
|
|
@ -410,6 +470,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
|
||||
const unsupportedFamily = broadBusinessEvaluation?.family
|
||||
? broadBusinessEvaluation.family
|
||||
: !explicitIntentCandidate && counterpartyBidirectionalValueFlow?.family
|
||||
? counterpartyBidirectionalValueFlow.family
|
||||
: !explicitIntentCandidate && counterpartyTurnover?.family
|
||||
? counterpartyTurnover.family
|
||||
: null;
|
||||
|
|
@ -417,6 +479,9 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
if (supportedIntent?.reason) {
|
||||
reasonCodes.push(supportedIntent.reason);
|
||||
}
|
||||
if (counterpartyBidirectionalValueFlow?.family) {
|
||||
reasonCodes.push("counterparty_bidirectional_value_flow_current_turn_signal");
|
||||
}
|
||||
if (counterpartyTurnover?.family) {
|
||||
reasonCodes.push("counterparty_turnover_current_turn_signal");
|
||||
}
|
||||
|
|
@ -443,6 +508,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
? "inventory"
|
||||
: broadBusinessEvaluation?.family
|
||||
? "business_summary"
|
||||
: counterpartyBidirectionalValueFlow?.family
|
||||
? "counterparty_value"
|
||||
: explicitIntentCandidate?.includes("counterparty")
|
||||
? "counterparty"
|
||||
: counterpartyTurnover?.family
|
||||
|
|
@ -455,6 +522,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
? "confirmed_snapshot"
|
||||
: broadBusinessEvaluation?.family
|
||||
? "broad_evaluation"
|
||||
: counterpartyBidirectionalValueFlow?.family
|
||||
? "net_value_flow"
|
||||
: explicitIntentCandidate === "customer_revenue_and_payments" ||
|
||||
explicitIntentCandidate === "supplier_payouts_profile"
|
||||
? "counterparty_value_or_turnover"
|
||||
|
|
@ -470,7 +539,10 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
? "counterparty_value_or_turnover"
|
||||
: null;
|
||||
const staleReplayForbidden = Boolean(
|
||||
unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate)
|
||||
unsupportedFamily ||
|
||||
broadBusinessEvaluation?.family ||
|
||||
(counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) ||
|
||||
(counterpartyTurnover?.entity && !explicitIntentCandidate)
|
||||
);
|
||||
return {
|
||||
schema_version: "assistant_turn_meaning_v1",
|
||||
|
|
@ -481,10 +553,13 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
|
|||
asked_domain_family: askedDomainFamily,
|
||||
asked_action_family: askedActionFamily,
|
||||
explicit_intent_candidate: explicitIntentCandidate,
|
||||
explicit_entity_candidates: broadBusinessEvaluation?.family ? [] : buildEntityCandidates(counterpartyTurnover),
|
||||
explicit_entity_candidates: broadBusinessEvaluation?.family
|
||||
? []
|
||||
: buildEntityCandidates(counterpartyBidirectionalValueFlow ?? counterpartyTurnover),
|
||||
meaning_confidence: broadBusinessEvaluation?.family
|
||||
? "medium"
|
||||
: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"),
|
||||
: supportedIntent?.confidence ??
|
||||
(counterpartyBidirectionalValueFlow?.family || counterpartyTurnover?.family ? "medium" : "low"),
|
||||
intent_override_strength: explicitIntentCandidate
|
||||
? "explicit_current_turn_intent"
|
||||
: staleReplayForbidden
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ type V2FamilyFragment = V2Family["fragments"][number];
|
|||
type RouteQueryClass =
|
||||
| "exact_object_trace"
|
||||
| "ranking_or_period_summary"
|
||||
| "bidirectional_value_flow"
|
||||
| "symptom_first"
|
||||
| "lifecycle_first"
|
||||
| "chain_break"
|
||||
|
|
@ -66,6 +67,10 @@ interface RouteDisciplineRule {
|
|||
const ACCOUNT_HINT_PATTERN =
|
||||
/(?:\b(?:account|acct|schet|счет|сч)\s*[:#]?\s*(?:[1-9][0-9](?:[./-][0-9]{1,2})?)|\b(?:19|20|21|23|25|26|28|29|44|51|60|62|68)\b)/i;
|
||||
const PERIOD_PATTERN = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/i;
|
||||
const BIDIRECTIONAL_VALUE_FLOW_PATTERN =
|
||||
/(?:\b(?:receive(?:d)?|received|get|got|incoming|inflow|paid|payment|payments|outgoing|outflow|net|netto|cash\s*flow)\b|получ(?:ить|ил[аи]?|ено|аем|или)|поступ(?:ил[аи]?|ление|ления)|заплат(?:ить|ил[аи]?|или)|оплат(?:ить|ил[аи]?|ы|или)|входящ(?:ий|ие|их)|исходящ(?:ий|ие|их)|нетто|сальдо)/iu;
|
||||
const COUNTERPARTY_SCOPE_PATTERN =
|
||||
/(?:\b(?:counterparty|supplier|customer|vendor|client|bank)\b|контрагент|поставщик|покупател|клиент|заказчик|банк|сбербанк|по\s+(?:ип|ооо|пао|зао|оао|группа)\b)/iu;
|
||||
const SYMPTOM_MARKER_PATTERN =
|
||||
/(?:\bsymptom\b|\banomaly\b|\bproblem\b|\bissue\b|\btail\b|\bhanging\b|\bblocked\b|\bincomplete\b|remains?\s+open|not\s+(?:confirmed|observed|resolved|closed)|не\s+(?:подтвержден|закрыт|наблюдается)|хвост|сбой|проблем)/i;
|
||||
const LIFECYCLE_MARKER_PATTERN =
|
||||
|
|
@ -96,6 +101,13 @@ export const ROUTE_DISCIPLINE_RULE_TABLE: RouteDisciplineRule[] = [
|
|||
forbidden_fallback: ["store_canonical", "hybrid_store_plus_live"],
|
||||
description: "Ranking and period summary queries require analytical batch path."
|
||||
},
|
||||
{
|
||||
query_class: "bidirectional_value_flow",
|
||||
required_route: "hybrid_store_plus_live",
|
||||
allowed_fallback: ["no_route"],
|
||||
forbidden_fallback: ["store_canonical"],
|
||||
description: "Scoped bidirectional value-flow questions require hybrid evidence path."
|
||||
},
|
||||
{
|
||||
query_class: "symptom_first",
|
||||
required_route: "hybrid_store_plus_live",
|
||||
|
|
@ -218,6 +230,21 @@ function hasAccountOrPeriodAnchor(fragment: V2FamilyFragment, lowerText: string)
|
|||
return fragment.account_hints.length > 0 || ACCOUNT_HINT_PATTERN.test(lowerText) || PERIOD_PATTERN.test(lowerText);
|
||||
}
|
||||
|
||||
function hasBidirectionalValueFlowSignal(fragment: V2FamilyFragment, lowerText: string): boolean {
|
||||
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
|
||||
return false;
|
||||
}
|
||||
return BIDIRECTIONAL_VALUE_FLOW_PATTERN.test(lowerText);
|
||||
}
|
||||
|
||||
function hasCounterpartyScopeSignal(fragment: V2FamilyFragment, lowerText: string): boolean {
|
||||
return (
|
||||
COUNTERPARTY_SCOPE_PATTERN.test(lowerText) ||
|
||||
fragment.entity_hints.some((hint) => hint.trim().length > 0) ||
|
||||
fragment.candidate_labels.includes("cross_entity")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRouteClass(fragment: V2FamilyFragment): RouteDisciplineRule {
|
||||
const lowerText = mergedFragmentText(fragment);
|
||||
const symptomSignal = hasSymptomSignal(fragment, lowerText);
|
||||
|
|
@ -227,6 +254,8 @@ function resolveRouteClass(fragment: V2FamilyFragment): RouteDisciplineRule {
|
|||
const causalSignal = hasCausalSignal(lowerText);
|
||||
const ambiguitySignal = hasAmbiguitySignal(fragment, lowerText);
|
||||
const accountOrPeriodAnchor = hasAccountOrPeriodAnchor(fragment, lowerText);
|
||||
const bidirectionalValueFlowSignal = hasBidirectionalValueFlowSignal(fragment, lowerText);
|
||||
const counterpartyScopeSignal = hasCounterpartyScopeSignal(fragment, lowerText);
|
||||
|
||||
if (fragment.flags.asks_for_exact_object_trace) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("exact_object_trace")!;
|
||||
|
|
@ -234,6 +263,9 @@ function resolveRouteClass(fragment: V2FamilyFragment): RouteDisciplineRule {
|
|||
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("ranking_or_period_summary")!;
|
||||
}
|
||||
if (bidirectionalValueFlowSignal && counterpartyScopeSignal) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("bidirectional_value_flow")!;
|
||||
}
|
||||
if (ambiguitySignal && (symptomSignal || lifecycleSignal || chainBreakSignal || periodImpactSignal || causalSignal)) {
|
||||
return ROUTE_DISCIPLINE_RULE_MAP.get("mixed_ambiguity")!;
|
||||
}
|
||||
|
|
@ -272,7 +304,8 @@ function shouldPromoteFromNoRoute(fragment: V2FamilyFragment, rule: RouteDiscipl
|
|||
hasLifecycleSignal(fragment, lowerText) ||
|
||||
hasChainBreakSignal(lowerText) ||
|
||||
hasPeriodImpactSignal(lowerText) ||
|
||||
hasCausalSignal(lowerText);
|
||||
hasCausalSignal(lowerText) ||
|
||||
(hasBidirectionalValueFlowSignal(fragment, lowerText) && hasCounterpartyScopeSignal(fragment, lowerText));
|
||||
|
||||
const hasAnchor =
|
||||
hasAccountOrPeriodAnchor(fragment, lowerText) ||
|
||||
|
|
|
|||
|
|
@ -587,6 +587,7 @@ describe("address compose stage utf8 headers", () => {
|
|||
expect(reply.text).toContain("не подтвержденная клиентская выручка");
|
||||
expect(reply.text).toContain("Сводка по направлению");
|
||||
expect(reply.text).toContain("Основание 1С");
|
||||
expect(reply.text).toContain("По смыслу это скорее финансовый/банковский контур");
|
||||
expect(reply.text).toContain("вид операции/назначение платежа/договор");
|
||||
});
|
||||
|
||||
|
|
@ -613,6 +614,8 @@ describe("address compose stage utf8 headers", () => {
|
|||
|
||||
expect(reply.text).toContain("Сводка по направлению");
|
||||
expect(reply.text).toContain("Это не обычный поставщик автоматически");
|
||||
expect(reply.text).toContain("Примеры строк 1С:");
|
||||
expect(reply.text).toContain("Следующий шаг: могу отдельно разложить назначения платежа");
|
||||
expect(reply.text).toContain("Показаны первые 5 из 8");
|
||||
expect(reply.text).not.toContain("00000000007");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -443,4 +443,86 @@ describe("address reply builders regressions", () => {
|
|||
expect(result.text.split("\n")[0]).toContain("1.000,00");
|
||||
expect(result.text).not.toContain("встречных остатков");
|
||||
});
|
||||
it("avoids mixed stock quantity as a fake management KPI in on-hand snapshot answers", () => {
|
||||
const result = composeInventoryReply(
|
||||
"inventory_on_hand_as_of_date",
|
||||
[
|
||||
{
|
||||
amount: 695360,
|
||||
quantity: 3,
|
||||
item: "Рабочая станция",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period: "2017-06-30",
|
||||
registrator: "Остатки"
|
||||
} as any,
|
||||
{
|
||||
amount: 295526.51,
|
||||
quantity: 317000,
|
||||
item: "Лифлеты",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
period: "2017-06-30",
|
||||
registrator: "Остатки"
|
||||
} as any
|
||||
],
|
||||
{
|
||||
userMessage: "какие остатки на июнь 2017",
|
||||
asOfDate: "2017-06-30"
|
||||
},
|
||||
{
|
||||
resolvePayablesAsOfDate: () => "2017-06-30",
|
||||
buildInventoryOnHandAggregate: () => [
|
||||
{
|
||||
item: "Рабочая станция",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
quantity: 3,
|
||||
amount: 695360,
|
||||
operations: 1,
|
||||
firstPeriod: "2017-06-30",
|
||||
lastPeriod: "2017-06-30",
|
||||
sourceRefs: []
|
||||
},
|
||||
{
|
||||
item: "Лифлеты",
|
||||
warehouse: "Основной склад",
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
quantity: 317000,
|
||||
amount: 295526.51,
|
||||
operations: 1,
|
||||
firstPeriod: "2017-06-30",
|
||||
lastPeriod: "2017-06-30",
|
||||
sourceRefs: []
|
||||
}
|
||||
],
|
||||
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
|
||||
formatDateRu: (value: string) => value,
|
||||
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
|
||||
formatMoneyRub: (value: number) => `${value} ₽`,
|
||||
isInventoryPurchaseMovement: () => false,
|
||||
summarizeInventoryTraceRows: () => ({
|
||||
item: null,
|
||||
warehouses: [],
|
||||
organizations: [],
|
||||
counterparties: [],
|
||||
documents: [],
|
||||
firstPeriod: null,
|
||||
lastPeriod: null,
|
||||
totalAmount: 0
|
||||
}),
|
||||
formatInventoryTraceRows: () => [],
|
||||
hasInventoryPurchaseDateActionFocus: () => false,
|
||||
inventoryTraceDateLabel: () => "",
|
||||
extractInventoryCounterpartyCandidates: () => [],
|
||||
buildInventoryAgingByItemAggregate: () => [],
|
||||
formatInventoryAgingRows: () => [],
|
||||
isInventorySaleMovement: () => false
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.text).not.toContain("Суммарное количество");
|
||||
expect(result?.text).toContain("Общее количество не свожу в один управленческий показатель");
|
||||
expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3342,6 +3342,107 @@ describe("assistant MCP discovery turn input adapter", () => {
|
|||
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context");
|
||||
});
|
||||
|
||||
it("pivots a business-overview follow-up with explicit counterparty net-flow wording back to counterparty value flow", () => {
|
||||
const orgName =
|
||||
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
|
||||
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "unknown",
|
||||
asked_action_family: "unknown"
|
||||
},
|
||||
followupContext: {
|
||||
previous_discovery_pilot_scope: "business_overview_route_template_v1",
|
||||
previous_filters: {
|
||||
organization: orgName,
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
|
||||
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
|
||||
expect(result.data_need_graph?.subject_candidates).toEqual([counterpartyName]);
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "net_value_flow",
|
||||
explicit_entity_candidates: [counterpartyName],
|
||||
explicit_organization_scope: orgName,
|
||||
explicit_date_scope: "2020",
|
||||
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
|
||||
stale_replay_forbidden: true
|
||||
});
|
||||
expect(result.reason_codes).toContain(
|
||||
"mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow"
|
||||
);
|
||||
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_raw_scope");
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_broad_business_evaluation_route_candidate");
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty");
|
||||
});
|
||||
|
||||
it("survives poisoned business-overview turn meaning when predecompose still carries the counterparty", () => {
|
||||
const orgName =
|
||||
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
|
||||
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
|
||||
const result = buildAssistantMcpDiscoveryTurnInput({
|
||||
userMessage:
|
||||
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
|
||||
effectiveMessage:
|
||||
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0443\u043c\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432, \u0441\u0443\u043c\u043c\u0443 \u0432\u044b\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432 \u0438 \u0447\u0438\u0441\u0442\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c (\u043d\u0435\u0442\u0442\u043e) \u0434\u043b\u044f \u0433\u0440\u0443\u043f\u043f\u044b \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0439 \u0421\u0412\u041a \u0437\u0430 2020 \u0433\u043e\u0434.",
|
||||
assistantTurnMeaning: {
|
||||
asked_domain_family: "business_overview",
|
||||
asked_action_family: "profit_margin_boundary",
|
||||
explicit_date_scope: "2020",
|
||||
unsupported_but_understood_family: "profit_margin_boundary",
|
||||
stale_replay_forbidden: true
|
||||
},
|
||||
predecomposeContract: {
|
||||
entities: {
|
||||
counterparty: counterpartyName
|
||||
},
|
||||
period: {
|
||||
scope: "range",
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
}
|
||||
},
|
||||
followupContext: {
|
||||
previous_discovery_pilot_scope: "business_overview_route_template_v1",
|
||||
previous_filters: {
|
||||
organization: orgName,
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.adapter_status).toBe("ready");
|
||||
expect(result.should_run_discovery).toBe(true);
|
||||
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
|
||||
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
|
||||
expect(result.data_need_graph?.subject_candidates).toEqual([counterpartyName]);
|
||||
expect(result.turn_meaning_ref).toMatchObject({
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "net_value_flow",
|
||||
explicit_entity_candidates: [counterpartyName],
|
||||
explicit_organization_scope: orgName,
|
||||
explicit_date_scope: "2020",
|
||||
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
|
||||
stale_replay_forbidden: true
|
||||
});
|
||||
expect(result.reason_codes).toContain(
|
||||
"mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow"
|
||||
);
|
||||
expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_predecompose");
|
||||
expect(result.reason_codes).not.toContain("mcp_discovery_broad_business_evaluation_route_candidate");
|
||||
});
|
||||
|
||||
it("lets an explicit VAT follow-up stay on the exact VAT route instead of stale business overview", () => {
|
||||
const orgName =
|
||||
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
|
||||
|
|
|
|||
|
|
@ -57,6 +57,34 @@ describe("assistantTurnMeaningPolicy", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("keeps counterparty net-flow wording in counterparty semantics even if effective text mentions profit", () => {
|
||||
const policy = buildPolicy({
|
||||
resolveAddressIntent: () => ({ intent: "unknown", confidence: "low" })
|
||||
});
|
||||
|
||||
const meaning = policy.resolveAssistantTurnMeaning({
|
||||
rawUserMessage:
|
||||
"\u0410 \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020: \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438, \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438 \u0438 \u043a\u0430\u043a\u043e\u0435 \u043d\u0435\u0442\u0442\u043e?",
|
||||
effectiveAddressUserMessage:
|
||||
"\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0443\u043c\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432, \u0441\u0443\u043c\u043c\u0443 \u0432\u044b\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432 \u0438 \u0447\u0438\u0441\u0442\u0443\u044e \u043f\u0440\u0438\u0431\u044b\u043b\u044c (\u043d\u0435\u0442\u0442\u043e) \u0434\u043b\u044f \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a \u0437\u0430 2020 \u0433\u043e\u0434."
|
||||
});
|
||||
|
||||
expect(meaning.explicit_intent_candidate).toBeNull();
|
||||
expect(meaning.asked_domain_family).toBe("counterparty_value");
|
||||
expect(meaning.asked_action_family).toBe("net_value_flow");
|
||||
expect(meaning.unsupported_but_understood_family).toBe("counterparty_bidirectional_value_flow_or_netting");
|
||||
expect(meaning.explicit_entity_candidates).toEqual([
|
||||
{
|
||||
type: "counterparty",
|
||||
value: "\u0433\u0440\u0443\u043f\u043f\u0430 \u0441\u0432\u043a",
|
||||
source: "current_turn_loose_entity_tail"
|
||||
}
|
||||
]);
|
||||
expect(meaning.reason_codes).toContain("counterparty_bidirectional_value_flow_current_turn_signal");
|
||||
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
|
||||
expect(meaning.stale_replay_forbidden).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores temporal tail words in all-time revenue ranking questions", () => {
|
||||
const policy = buildPolicy({
|
||||
resolveAddressIntent: (text: string) =>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@ describe("counterparty analytics reply builders", () => {
|
|||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Оборот по СВК за доступное время: 3.500,00");
|
||||
expect(reply.text).toContain("по 2 подтвержденным входящим операциям");
|
||||
expect(reply.text).toContain("Это денежный поток от клиента, а не чистая прибыль");
|
||||
expect(reply.text).toContain("Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.");
|
||||
expect(reply.text).toContain("Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.");
|
||||
expect(reply.text).not.toContain("Самый доходный клиент");
|
||||
expect(reply.text).not.toContain("Топ-");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -225,4 +225,63 @@ describe("routeHintAdapter", () => {
|
|||
}
|
||||
expect(summary.decisions[0]?.route).toBe("store_canonical");
|
||||
});
|
||||
|
||||
it("routes counterparty received-paid-net wording to hybrid instead of canonical fact lookup", () => {
|
||||
const summary = toRouteHintSummary({
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "high",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
|
||||
normalized_fragment_text:
|
||||
"Определить сумму полученных средств, сумму выплаченных средств и чистый остаток (нетто) для контрагента Группа СВК за период 2020 года.",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: ["Группа СВК"],
|
||||
account_hints: [],
|
||||
document_hints: [],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "explicit",
|
||||
value: "2020",
|
||||
confidence: "high"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "high",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
});
|
||||
|
||||
expect(summary.mode).toBe("deterministic_v2");
|
||||
if (summary.mode !== "deterministic_v2") {
|
||||
throw new Error("Expected deterministic_v2 summary");
|
||||
}
|
||||
expect(summary.decisions[0]?.route).toBe("hybrid_store_plus_live");
|
||||
expect(summary.decisions[0]?.reason).toContain("bidirectional_value_flow");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NDC AI Normalizer Playground</title>
|
||||
<script type="module" crossorigin src="/assets/index-6meEannb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BURU4_Sm.css">
|
||||
<script type="module" crossorigin src="/assets/index-9w9i5XPJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DW_SonhM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
ManualCaseDecision,
|
||||
PromptState
|
||||
} from "../state/types";
|
||||
import { buildAutoRunDialogExportForCopy, type ConversationExportMode } from "../utils/conversationExport";
|
||||
import { AssistantPanel } from "./AssistantPanel";
|
||||
import { ConnectionPanel } from "./ConnectionPanel";
|
||||
import { JsonView } from "./JsonView";
|
||||
|
|
@ -620,6 +621,37 @@ function CardStopIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back to the legacy path below.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
function GroupChevronIcon({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<svg className={expanded ? "autoruns-group-chevron-svg expanded" : "autoruns-group-chevron-svg"} viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
|
|
@ -697,6 +729,9 @@ export function AutoRunsHistoryPanel({
|
|||
const [dialogBusy, setDialogBusy] = useState(false);
|
||||
const [annotationsBusy, setAnnotationsBusy] = useState(false);
|
||||
const [annotationResolutionBusyId, setAnnotationResolutionBusyId] = useState("");
|
||||
const dialogCopyResetTimerRef = useRef<number | null>(null);
|
||||
const [dialogCopyState, setDialogCopyState] = useState<"idle" | "success" | "error">("idle");
|
||||
const [dialogCopyModeLabel, setDialogCopyModeLabel] = useState<"чат" | "тех">("чат");
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [assistantLiveSessionId, setAssistantLiveSessionId] = useState("");
|
||||
const [assistantLiveConversation, setAssistantLiveConversation] = useState<AssistantConversationItem[]>([]);
|
||||
|
|
@ -941,6 +976,14 @@ export function AutoRunsHistoryPanel({
|
|||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dialogCopyResetTimerRef.current !== null) {
|
||||
window.clearTimeout(dialogCopyResetTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyIdentifierToClipboard = useCallback(
|
||||
async (event: React.SyntheticEvent, valueRaw: string, label: string) => {
|
||||
event.stopPropagation();
|
||||
|
|
@ -950,19 +993,7 @@ export function AutoRunsHistoryPanel({
|
|||
return;
|
||||
}
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
await writeTextToClipboard(value);
|
||||
log(`${label} copied: ${value}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -973,6 +1004,49 @@ export function AutoRunsHistoryPanel({
|
|||
[log]
|
||||
);
|
||||
|
||||
const copyDialogConversation = useCallback(
|
||||
async (mode: ConversationExportMode) => {
|
||||
if (!dialog || dialog.messages.length === 0 || !selectedRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exportText = buildAutoRunDialogExportForCopy(
|
||||
{
|
||||
runId: selectedRunId,
|
||||
caseId: selectedCaseId || dialog.case_id || "n/a",
|
||||
sessionId: dialog.session_id,
|
||||
source: dialog.source,
|
||||
messages: dialog.messages,
|
||||
decomposition: dialog.decomposition,
|
||||
assistantMode: dialog.assistant_mode,
|
||||
annotations: dialog.annotations,
|
||||
runSummary: runDetail?.run ?? null,
|
||||
coverage: runDetail?.coverage ?? null,
|
||||
report: runDetail?.report ?? null
|
||||
},
|
||||
mode
|
||||
);
|
||||
|
||||
const copied = await writeTextToClipboard(exportText);
|
||||
setDialogCopyModeLabel(mode === "technical" ? "тех" : "чат");
|
||||
setDialogCopyState(copied ? "success" : "error");
|
||||
|
||||
if (dialogCopyResetTimerRef.current !== null) {
|
||||
window.clearTimeout(dialogCopyResetTimerRef.current);
|
||||
}
|
||||
dialogCopyResetTimerRef.current = window.setTimeout(() => {
|
||||
setDialogCopyState("idle");
|
||||
}, 2200);
|
||||
|
||||
if (copied) {
|
||||
log(`Dialog ${mode === "technical" ? "technical" : "chat"} copied: run=${selectedRunId} case=${selectedCaseId || dialog.case_id}`);
|
||||
} else {
|
||||
log(`Dialog copy failed: run=${selectedRunId} case=${selectedCaseId || dialog.case_id}`);
|
||||
}
|
||||
},
|
||||
[dialog, log, runDetail, selectedCaseId, selectedRunId]
|
||||
);
|
||||
|
||||
function startAssistantLiveStatusTicker(): () => void {
|
||||
let index = 0;
|
||||
setAssistantLiveStatus(ASSISTANT_STAGES[0]);
|
||||
|
|
@ -3419,6 +3493,34 @@ export function AutoRunsHistoryPanel({
|
|||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="autoruns-dialog-copy-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-copy-btn"
|
||||
onClick={() => {
|
||||
void copyDialogConversation("default");
|
||||
}}
|
||||
disabled={dialogBusy || detailBusy || (dialog?.messages.length ?? 0) === 0}
|
||||
title="Скопировать question-answer диалог текущего прогона"
|
||||
>
|
||||
Скопировать чат
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-copy-btn"
|
||||
onClick={() => {
|
||||
void copyDialogConversation("technical");
|
||||
}}
|
||||
disabled={dialogBusy || detailBusy || (dialog?.messages.length ?? 0) === 0}
|
||||
title="Скопировать диалог вместе с debug JSON и метаданными прогона"
|
||||
>
|
||||
Скопировать техчат
|
||||
</button>
|
||||
<div className="autoruns-dialog-copy-status">
|
||||
{dialogCopyState === "success" ? <span className="assistant-copy-feedback success">Скопировано ({dialogCopyModeLabel})</span> : null}
|
||||
{dialogCopyState === "error" ? <span className="assistant-copy-feedback error">Ошибка копирования</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ export interface AutoRunDialogMessage {
|
|||
created_at: string | null;
|
||||
trace_id: string | null;
|
||||
reply_type: string | null;
|
||||
debug?: unknown | null;
|
||||
message_index: number;
|
||||
case_id?: string | null;
|
||||
case_message_index?: number | null;
|
||||
|
|
|
|||
|
|
@ -1263,6 +1263,21 @@ button:disabled {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.autoruns-dialog-copy-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.autoruns-dialog-copy-status {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.autoruns-case-list {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,33 @@ export interface ConversationExportItem {
|
|||
debug?: unknown | null;
|
||||
}
|
||||
|
||||
export interface AutoRunDialogExportItem {
|
||||
message_id: string | null;
|
||||
role: string;
|
||||
text: string;
|
||||
reply_type: string | null;
|
||||
created_at: string | null;
|
||||
trace_id: string | null;
|
||||
message_index: number;
|
||||
case_id?: string | null;
|
||||
case_message_index?: number | null;
|
||||
debug?: unknown | null;
|
||||
}
|
||||
|
||||
export interface AutoRunDialogExportPayload {
|
||||
runId: string;
|
||||
caseId: string;
|
||||
sessionId: string;
|
||||
source: string;
|
||||
messages: AutoRunDialogExportItem[];
|
||||
decomposition?: string[];
|
||||
assistantMode?: unknown | null;
|
||||
annotations?: unknown[];
|
||||
runSummary?: unknown | null;
|
||||
coverage?: unknown | null;
|
||||
report?: unknown | null;
|
||||
}
|
||||
|
||||
const DEBUG_SECTION_PATTERN =
|
||||
/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json|route_summary_json|debug_payload|technical_breakdown)\b/i;
|
||||
|
||||
|
|
@ -28,6 +55,10 @@ function stringifyDebug(value: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizeRole(value: string): "user" | "assistant" {
|
||||
return value === "assistant" ? "assistant" : "user";
|
||||
}
|
||||
|
||||
export function sanitizeConversationExportText(value: string): string {
|
||||
const raw = String(value ?? "");
|
||||
const cutMatch = raw.match(DEBUG_SECTION_PATTERN);
|
||||
|
|
@ -83,3 +114,113 @@ export function buildConversationExportForCopy(
|
|||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildAutoRunDialogExportForCopy(
|
||||
payload: AutoRunDialogExportPayload,
|
||||
mode: ConversationExportMode = "default"
|
||||
): string {
|
||||
const includeDebug = mode === "technical";
|
||||
const lines: string[] = [];
|
||||
lines.push("# Autorun dialog export");
|
||||
lines.push(`run_id: ${payload.runId || "n/a"}`);
|
||||
lines.push(`case_id: ${payload.caseId || "n/a"}`);
|
||||
lines.push(`session_id: ${payload.sessionId || "n/a"}`);
|
||||
lines.push(`source: ${payload.source || "n/a"}`);
|
||||
lines.push(`export_mode: ${mode}`);
|
||||
lines.push(`exported_at: ${new Date().toISOString()}`);
|
||||
lines.push("");
|
||||
|
||||
for (let index = 0; index < payload.messages.length; index += 1) {
|
||||
const item = payload.messages[index];
|
||||
const role = normalizeRole(item.role);
|
||||
const safeText = sanitizeConversationExportText(item.text || "");
|
||||
lines.push(`## ${index + 1}. ${role}`);
|
||||
lines.push(`message_index: ${item.message_index}`);
|
||||
if (item.case_id) {
|
||||
lines.push(`case_id: ${item.case_id}`);
|
||||
}
|
||||
if (typeof item.case_message_index === "number") {
|
||||
lines.push(`case_message_index: ${item.case_message_index}`);
|
||||
}
|
||||
if (item.created_at) {
|
||||
lines.push(`created_at: ${item.created_at}`);
|
||||
}
|
||||
if (includeDebug) {
|
||||
lines.push(`reply_type: ${item.reply_type ?? "n/a"}`);
|
||||
if (item.trace_id) {
|
||||
lines.push(`trace_id: ${item.trace_id}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(safeText || "(empty)");
|
||||
lines.push("");
|
||||
|
||||
if (includeDebug && role === "assistant" && item.debug) {
|
||||
lines.push("### technical_debug_payload_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(item.debug));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeDebug) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.push("### dialog_messages_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(payload.messages));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
|
||||
if ((payload.decomposition ?? []).length > 0) {
|
||||
lines.push("### decomposition_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(payload.decomposition));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (payload.assistantMode) {
|
||||
lines.push("### assistant_mode_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(payload.assistantMode));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if ((payload.annotations ?? []).length > 0) {
|
||||
lines.push("### annotations_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(payload.annotations));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (payload.runSummary) {
|
||||
lines.push("### run_summary_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(payload.runSummary));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (payload.coverage) {
|
||||
lines.push("### coverage_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(payload.coverage));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (payload.report) {
|
||||
lines.push("### run_report_json");
|
||||
lines.push("```json");
|
||||
lines.push(stringifyDebug(payload.report));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ DEFAULT_INVARIANT_SEVERITY: dict[str, str] = {
|
|||
"top_level_noise_present": "P0",
|
||||
"business_direct_answer_missing": "P0",
|
||||
"technical_garbage_in_answer": "P0",
|
||||
"counterparty_value_flow_misrouted_to_company_profit": "P0",
|
||||
"answer_layering_noise": "P1",
|
||||
"business_answer_too_verbose": "P1",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ def append_finding(
|
|||
BUSINESS_REVIEW_FINDING_MESSAGES = {
|
||||
"technical_garbage_in_answer": "User-facing answer leaked internal runtime or MCP identifiers.",
|
||||
"business_direct_answer_missing": "The answer did not put the direct business answer first.",
|
||||
"counterparty_value_flow_misrouted_to_company_profit": "Counterparty received/paid/net flow question was answered with company profit instead of counterparty cashflow.",
|
||||
"answer_layering_noise": "The answer opened with scaffolding or report framing instead of a clean business result.",
|
||||
"business_answer_too_verbose": "The answer is too verbose for a direct business question.",
|
||||
}
|
||||
|
|
@ -329,6 +330,7 @@ BUSINESS_REVIEW_FINDING_MESSAGES = {
|
|||
BUSINESS_REVIEW_FINDING_SEVERITY = {
|
||||
"technical_garbage_in_answer": "critical",
|
||||
"business_direct_answer_missing": "critical",
|
||||
"counterparty_value_flow_misrouted_to_company_profit": "critical",
|
||||
"answer_layering_noise": "critical",
|
||||
"business_answer_too_verbose": "warning",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,30 @@ SAFE_FINANCIAL_BOUNDARY_MARKERS = (
|
|||
"без назначения платеж",
|
||||
"без договора",
|
||||
)
|
||||
COUNTERPARTY_VALUE_FLOW_QUESTION_RE = re.compile(
|
||||
"(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\\s+.*\u043f\u043e\u043b\u0443\u0447|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\\s+.*\u0437\u0430\u043f\u043b\u0430\u0442|\u043a\u0430\u043a\u043e\u0435\\s+\u043d\u0435\u0442\u0442\u043e|\u043a\u0430\u043a\u043e\u0435\\s+\u0441\u0430\u043b\u044c\u0434\u043e)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
COUNTERPARTY_SCOPE_QUESTION_RE = re.compile(
|
||||
"(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043b\u0438\u0435\u043d\u0442|\u0441\u0432\u043a|\u0447\u0435\u043f\u0443\u0440\u043d\u043e\u0432)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
COUNTERPARTY_VALUE_FLOW_ANSWER_RE = re.compile(
|
||||
"(?:\u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438|\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e|\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043e\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0432\\s+\u043d\u0430\u0448\u0443\\s+\u0441\u0442\u043e\u0440\u043e\u043d\u0443|\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445\\s+\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445\\s+\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
COUNTERPARTY_VALUE_FLOW_REQUIRED_ANSWER_RE = re.compile(
|
||||
"(?:\u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438|\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e|\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043e\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445\\s+\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445\\s+\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
COUNTERPARTY_ANSWER_SCOPE_RE = re.compile(
|
||||
"(?:\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043b\u0438\u0435\u043d\u0442|\u0441\u0432\u043a|\u0447\u0435\u043f\u0443\u0440\u043d\u043e\u0432)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
COMPANY_PROFIT_ANSWER_RE = re.compile(
|
||||
"(?:\u0447\u0438\u0441\u0442\u0430\u044f\\s+\u043f\u0440\u0438\u0431\u044b\u043b\u044c|\u043f\u0440\u0438\u0431\u044b\u043b\u044c\u044e|90/91/99|\u0444\u0438\u043d\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|\u0443\u0431\u044b\u0442\u043e\u043a)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
|
|
@ -335,6 +359,7 @@ def build_step_for_pair(pair: dict[str, Any]) -> dict[str, Any]:
|
|||
"answer_layering_noise": "P1",
|
||||
"business_answer_too_verbose": "P1",
|
||||
"bank_counterparty_misclassified_as_business_partner": "P1",
|
||||
"counterparty_value_flow_misrouted_to_company_profit": "P0",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -349,12 +374,41 @@ def marker_hits(text: str, markers: tuple[str, ...]) -> list[str]:
|
|||
return [marker for marker in markers if marker and marker.casefold() in lowered]
|
||||
|
||||
|
||||
def detect_counterparty_value_flow_profit_mismatch(question: str, assistant_text: str) -> dict[str, Any] | None:
|
||||
question_text = str(question or "")
|
||||
answer_text = str(assistant_text or "")
|
||||
question_flow_match = COUNTERPARTY_VALUE_FLOW_QUESTION_RE.search(question_text)
|
||||
question_scope_match = COUNTERPARTY_SCOPE_QUESTION_RE.search(question_text)
|
||||
if not question_flow_match or not question_scope_match:
|
||||
return None
|
||||
|
||||
profit_match = COMPANY_PROFIT_ANSWER_RE.search(answer_text)
|
||||
if not profit_match:
|
||||
return None
|
||||
|
||||
value_flow_match = COUNTERPARTY_VALUE_FLOW_ANSWER_RE.search(answer_text)
|
||||
required_flow_match = COUNTERPARTY_VALUE_FLOW_REQUIRED_ANSWER_RE.search(answer_text)
|
||||
answer_scope_match = COUNTERPARTY_ANSWER_SCOPE_RE.search(answer_text)
|
||||
if required_flow_match and answer_scope_match:
|
||||
return None
|
||||
|
||||
return {
|
||||
"question_flow_hit": question_flow_match.group(0),
|
||||
"question_scope_hit": question_scope_match.group(0),
|
||||
"profit_hit": profit_match.group(0),
|
||||
"value_flow_hit": value_flow_match.group(0) if value_flow_match else None,
|
||||
"required_flow_hit": required_flow_match.group(0) if required_flow_match else None,
|
||||
"answer_scope_hit": answer_scope_match.group(0) if answer_scope_match else None,
|
||||
}
|
||||
|
||||
|
||||
def augment_gui_business_review(step_state: dict[str, Any]) -> dict[str, Any]:
|
||||
review = (
|
||||
dict(step_state.get("business_first_review"))
|
||||
if isinstance(step_state.get("business_first_review"), dict)
|
||||
else {}
|
||||
)
|
||||
question = str(step_state.get("question_resolved") or step_state.get("question_template") or "")
|
||||
assistant_text = str(step_state.get("assistant_text") or "")
|
||||
issue_codes = [str(item) for item in review.get("issue_codes", []) if str(item).strip()]
|
||||
root_layers = [str(item) for item in review.get("suggested_root_cause_layers", []) if str(item).strip()]
|
||||
|
|
@ -378,6 +432,17 @@ def augment_gui_business_review(step_state: dict[str, Any]) -> dict[str, Any]:
|
|||
if "business_semantic_role_gap" not in root_layers:
|
||||
root_layers.append("business_semantic_role_gap")
|
||||
|
||||
mismatch_details = detect_counterparty_value_flow_profit_mismatch(question, assistant_text)
|
||||
if mismatch_details:
|
||||
issue_code = "counterparty_value_flow_misrouted_to_company_profit"
|
||||
if issue_code not in issue_codes:
|
||||
issue_codes.append(issue_code)
|
||||
if "followup_action_resolution_gap" not in root_layers:
|
||||
root_layers.append("followup_action_resolution_gap")
|
||||
if "answer_shape_mismatch" not in root_layers:
|
||||
root_layers.append("answer_shape_mismatch")
|
||||
review["semantic_mismatch_details"] = mismatch_details
|
||||
|
||||
review["technical_garbage_present"] = bool(technical_hits)
|
||||
review["technical_garbage_hits"] = technical_hits
|
||||
review["issue_codes"] = issue_codes
|
||||
|
|
|
|||
|
|
@ -170,6 +170,86 @@ class AssistantStage1RunReviewTests(unittest.TestCase):
|
|||
self.assertIn("technical_garbage_in_answer", review["summary"]["issue_counts"])
|
||||
self.assertIn("bank_counterparty_misclassified_as_business_partner", review["summary"]["issue_counts"])
|
||||
|
||||
def test_review_flags_counterparty_net_flow_answer_that_slips_into_company_profit(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sessions_dir = root / "sessions"
|
||||
reports_dir = root / "reports"
|
||||
run_id = "assistant-stage1-counterparty-profit-slip"
|
||||
session_file = sessions_dir / f"{run_id}-SAVED-001.json"
|
||||
report_file = reports_dir / f"{run_id}.md"
|
||||
write_json(
|
||||
session_file,
|
||||
session_payload(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"text": "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Нет, денежное операционное нетто не стоит считать чистой прибылью. "
|
||||
"По закрытию 90/91/99 подтвержден учетный убыток −7 136 815,85 ₽.",
|
||||
"reply_type": "factual_with_explanation",
|
||||
"message_id": "a-counterparty-profit-slip",
|
||||
"trace_id": "trace-counterparty-profit-slip",
|
||||
"debug": {},
|
||||
},
|
||||
]
|
||||
),
|
||||
)
|
||||
report_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_file.write_text(f"# Assistant Stage 1 Eval Run\n\n- run_id: {run_id}\n", encoding="utf-8")
|
||||
|
||||
review = reviewer.build_run_review(
|
||||
run_id=run_id,
|
||||
session_files=[session_file],
|
||||
report_path=report_file,
|
||||
)
|
||||
|
||||
self.assertEqual(review["summary"]["overall_business_status"], "fail")
|
||||
self.assertIn("counterparty_value_flow_misrouted_to_company_profit", review["summary"]["issue_counts"])
|
||||
target_by_issue = {item["issue_code"]: item for item in review["repair_targets"]}
|
||||
self.assertEqual(target_by_issue["counterparty_value_flow_misrouted_to_company_profit"]["severity"], "P0")
|
||||
|
||||
def test_review_does_not_flag_counterparty_net_flow_when_received_paid_answer_is_present(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
sessions_dir = root / "sessions"
|
||||
reports_dir = root / "reports"
|
||||
run_id = "assistant-stage1-counterparty-net-clean"
|
||||
session_file = sessions_dir / f"{run_id}-SAVED-001.json"
|
||||
report_file = reports_dir / f"{run_id}.md"
|
||||
write_json(
|
||||
session_file,
|
||||
session_payload(
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"text": "А теперь по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "По Группа СВК за 2020 получили 12 093 465 ₽, заплатили 0 ₽, денежное нетто +12 093 465 ₽ в нашу сторону.",
|
||||
"reply_type": "factual",
|
||||
"message_id": "a-counterparty-net-clean",
|
||||
"trace_id": "trace-counterparty-net-clean",
|
||||
"debug": {},
|
||||
},
|
||||
]
|
||||
),
|
||||
)
|
||||
report_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_file.write_text(f"# Assistant Stage 1 Eval Run\n\n- run_id: {run_id}\n", encoding="utf-8")
|
||||
|
||||
review = reviewer.build_run_review(
|
||||
run_id=run_id,
|
||||
session_files=[session_file],
|
||||
report_path=report_file,
|
||||
)
|
||||
|
||||
self.assertNotIn("counterparty_value_flow_misrouted_to_company_profit", review["summary"]["issue_counts"])
|
||||
|
||||
def test_question_quality_treats_short_natural_followups_as_contextual(self) -> None:
|
||||
pairs = [
|
||||
{"pair_index": 1, "user": {"text": "приветик - че как там дела"}},
|
||||
|
|
|
|||
Loading…
Reference in New Issue