Зафиксировать семантическую целостность VAT, debt mirror и trace-ответов

This commit is contained in:
2026-05-18 10:03:58 +03:00
parent a5fa940953
commit 9c86407937
16 changed files with 333 additions and 20 deletions
@@ -692,6 +692,32 @@ function isBroadDebtPolarityQuestion(intent, text) {
}
return /(?:^|[\s,.;:!?()\-])(?:кто|кому|какие|какой|список|топ|все|всех|всего)(?=$|[\s,.;:!?()\-])/iu.test(normalized);
}
function isShortDebtRoleMirrorFollowup(intent, text, followupContext) {
if (intent !== "payables_confirmed_as_of_date" && intent !== "receivables_confirmed_as_of_date") {
return false;
}
const previousIntent = followupContext?.previous_intent ?? null;
const previousIsPayables = previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties";
const previousIsReceivables = previousIntent === "receivables_confirmed_as_of_date" || previousIntent === "list_receivables_counterparties";
const normalized = textWithRepairedVariant(String(text ?? ""))
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[^\p{L}0-9]+/giu, " ")
.trim();
if (!normalized) {
return false;
}
const tokens = normalized.split(/\s+/u).filter(Boolean);
if (tokens.length > 4) {
return false;
}
const semanticTokens = /^(?:а|a|и|i)$/iu.test(tokens[0] ?? "") ? tokens.slice(1) : tokens;
const phrase = semanticTokens.join(" ");
const asksReceivables = phrase === "нам" || phrase === "нам кто" || phrase === "кто нам";
const asksPayables = phrase === "кому" || phrase === "мы кому" || phrase === "кому мы";
return ((intent === "receivables_confirmed_as_of_date" && previousIsPayables && asksReceivables) ||
(intent === "payables_confirmed_as_of_date" && previousIsReceivables && asksPayables));
}
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const merged = { ...current };
const reasons = [];
@@ -862,7 +888,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
const currentCounterparty = toNonEmptyString(merged.counterparty);
const suppressCounterpartyForBroadDebtQuestion = isBroadDebtPolarityQuestion(intent, userMessage) && !currentCounterparty;
const suppressCounterpartyForShortDebtMirror = isShortDebtRoleMirrorFollowup(intent, userMessage, followupContext) &&
followupContext.previous_anchor_type !== "counterparty" &&
(!currentCounterparty || isLowQualityCounterpartyAnchor(currentCounterparty));
const shouldInheritCounterparty = !suppressCounterpartyForBroadDebtQuestion &&
!suppressCounterpartyForShortDebtMirror &&
(!currentCounterparty ||
(Boolean(inheritedCounterparty) &&
isLowQualityCounterpartyAnchor(currentCounterparty) &&
@@ -870,6 +900,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
if (inheritedCounterparty && suppressCounterpartyForBroadDebtQuestion) {
reasons.push("counterparty_carryover_suppressed_for_broad_debt_polarity_question");
}
if (inheritedCounterparty && suppressCounterpartyForShortDebtMirror) {
reasons.push("counterparty_carryover_suppressed_for_short_debt_mirror");
}
if (inheritedCounterparty && shouldInheritCounterparty) {
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.composeInventoryReply = composeInventoryReply;
const replyContracts_1 = require("./replyContracts");
const inventoryReplyPresentation_1 = require("./inventoryReplyPresentation");
const INVENTORY_TRACE_EVIDENCE_ROW_LIMIT = 3;
function cleanupInventoryRequestedParty(value) {
const cleaned = String(value ?? "")
.replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|покупател|buyer|customer|item|product|sku)[\s\S]*$/iu, "")
@@ -236,7 +237,10 @@ function composeInventoryReply(intent, rows, options, deps) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.documents.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 8));
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT));
if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`);
}
}
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium", purchaseRows.length > 0));
}
@@ -353,9 +357,9 @@ function composeInventoryReply(intent, rows, options, deps) {
const itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine = summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.`
? `По номенклатуре ${itemLabel} в документах выбытия покупатель: ${summary.counterparties[0]}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает, что продали именно выбранный остаток/лот.`
: summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`
? `По номенклатуре ${itemLabel} в документах выбытия найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает связь с конкретным остатком/лотом.`
: `По товару ${itemLabel} покупатель в доступных данных не выделен.`;
const lines = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`);
@@ -363,17 +367,23 @@ function composeInventoryReply(intent, rows, options, deps) {
lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`);
lines.push(`- По доступным движениям номенклатура отгружалась покупателю: ${summary.counterparties[0]}.`);
}
else if (summary.counterparties.length > 1) {
lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`);
lines.push(`- По доступным движениям найдено несколько покупателей номенклатуры: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
else if (saleRows.length > 0) {
lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных.");
}
if (saleRows.length > 0) {
lines.push("- Без партионного учета этот ответ подтверждает продажи по номенклатуре, а не юридически точную продажу конкретного остатка или партии.");
}
lines.push("", "Документы выбытия:");
if (saleRows.length > 0) {
lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens));
lines.push(...deps.formatInventoryTraceRows(saleRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT, excludedCounterpartyTokens));
if (saleRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(saleRows.length)} найденных строк; полный след остается в подтвержденном срезе.`);
}
}
else {
lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных.");
@@ -415,6 +415,7 @@ function sameCounterpartyCandidate(left, right) {
function readGroundedDiscoveryCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) {
const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString);
const suppressDiscoveryEntityCarryover = discoveryPilotScope === "metadata_inspection_v1" ||
readAssistantMcpDiscoveryTurnMeaning(debug)?.stale_replay_forbidden === true ||
readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug);
if (suppressDiscoveryEntityCarryover) {
return null;
@@ -943,7 +943,8 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
}
if (rankingNeed) {
const incomingLeader = strongestIncomingYear(overview);
const netLeader = strongestNetYear(overview);
const canRankYearlyNet = !limitLine;
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
const leaderYear = toNonEmptyString(incomingLeader?.year_bucket);
const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru);
const leaderRows = Number(incomingLeader?.incoming_rows_with_amount);
@@ -964,7 +965,10 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
if (requestedFinancialBoundaryLine) {
lines.push(requestedFinancialBoundaryLine);
}
const yearRows = businessOverviewYearRowsLine(overview);
if (!canRankYearlyNet && Array.isArray(overview.yearly_breakdown) && overview.yearly_breakdown.length > 0) {
lines.push("Годовое операционное нетто в широком срезе не ранжирую: по одному из направлений достигнут лимит строк, поэтому безопаснее дозапросить конкретный год или квартал.");
}
const yearRows = canRankYearlyNet ? businessOverviewYearRowsLine(overview) : null;
if (yearRows) {
lines.push(yearRows);
}
@@ -7,6 +7,12 @@ function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е");
}
function hasSamePeriodReferenceCue(...values) {
return values
.map((value) => normalizeFollowupText(value))
.some((normalized) => normalized &&
/(?:\b(?:same|this|that)\s+period\b|(?:за|на)\s+(?:этот|тот|такой\s+же|тот\s+же)\s+период|(?:Р·Р°|РЅР°)\s+(?:этот|тот|такой\s+Р¶Рµ|тот\s+Р¶Рµ)\s+период)/iu.test(normalized));
}
function hasBankOperationsPivotCue(text) {
const normalized = normalizeFollowupText(text);
if (!normalized) {
@@ -955,7 +961,21 @@ function createAssistantTransitionPolicy(deps) {
hasInventoryRootRestatementAlternate ||
hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate));
const explicitIntentForCarryover = debtRoleSwapIntent ? debtRoleSwapIntent : explicitIntent;
const previousHasPeriodWindow = Boolean(deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to));
const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const shouldRetargetVatSamePeriod = previousHasPeriodWindow &&
hasSamePeriodReferenceCue(userMessage, alternateMessage) &&
(explicitIntent === "vat_payable_confirmed_as_of_date" ||
explicitIntent === "vat_payable_forecast" ||
explicitIntent === "vat_liability_confirmed_for_tax_period" ||
predecomposeIntent === "vat_payable_confirmed_as_of_date" ||
predecomposeIntent === "vat_liability_confirmed_for_tax_period" ||
deps.resolveAddressIntentFamily(explicitIntent) === "vat");
const explicitIntentForCarryover = shouldRetargetVatSamePeriod
? "vat_liability_confirmed_for_tax_period"
: debtRoleSwapIntent
? debtRoleSwapIntent
: explicitIntent;
const carryoverTargetIntent = (0, assistantContinuityPolicy_1.resolveFollowupTargetIntent)(inventoryPurchaseDateVatBridge, selectedObjectRetargetIntent, explicitIntentForCarryover, sourceIntent, followupSelectionMode, deps.toNonEmptyString(inventoryRootFrame?.intent), displayedEntityTargetIntent, previousIntent, explicitInventorySameDatePivot);
return {
followupContext: {