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

This commit is contained in:
dctouch 2026-05-18 10:03:58 +03:00
parent a5fa940953
commit 9c86407937
16 changed files with 333 additions and 20 deletions

View File

@ -692,6 +692,32 @@ function isBroadDebtPolarityQuestion(intent, text) {
} }
return /(?:^|[\s,.;:!?()\-])(?:кто|кому|какие|какой|список|топ|все|всех|всего)(?=$|[\s,.;:!?()\-])/iu.test(normalized); 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) { function mergeFollowupFilters(current, intent, userMessage, followupContext) {
const merged = { ...current }; const merged = { ...current };
const reasons = []; const reasons = [];
@ -862,7 +888,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null); (followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
const currentCounterparty = toNonEmptyString(merged.counterparty); const currentCounterparty = toNonEmptyString(merged.counterparty);
const suppressCounterpartyForBroadDebtQuestion = isBroadDebtPolarityQuestion(intent, userMessage) && !currentCounterparty; const suppressCounterpartyForBroadDebtQuestion = isBroadDebtPolarityQuestion(intent, userMessage) && !currentCounterparty;
const suppressCounterpartyForShortDebtMirror = isShortDebtRoleMirrorFollowup(intent, userMessage, followupContext) &&
followupContext.previous_anchor_type !== "counterparty" &&
(!currentCounterparty || isLowQualityCounterpartyAnchor(currentCounterparty));
const shouldInheritCounterparty = !suppressCounterpartyForBroadDebtQuestion && const shouldInheritCounterparty = !suppressCounterpartyForBroadDebtQuestion &&
!suppressCounterpartyForShortDebtMirror &&
(!currentCounterparty || (!currentCounterparty ||
(Boolean(inheritedCounterparty) && (Boolean(inheritedCounterparty) &&
isLowQualityCounterpartyAnchor(currentCounterparty) && isLowQualityCounterpartyAnchor(currentCounterparty) &&
@ -870,6 +900,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
if (inheritedCounterparty && suppressCounterpartyForBroadDebtQuestion) { if (inheritedCounterparty && suppressCounterpartyForBroadDebtQuestion) {
reasons.push("counterparty_carryover_suppressed_for_broad_debt_polarity_question"); 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) { if (inheritedCounterparty && shouldInheritCounterparty) {
merged.counterparty = inheritedCounterparty; merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");

View File

@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.composeInventoryReply = composeInventoryReply; exports.composeInventoryReply = composeInventoryReply;
const replyContracts_1 = require("./replyContracts"); const replyContracts_1 = require("./replyContracts");
const inventoryReplyPresentation_1 = require("./inventoryReplyPresentation"); const inventoryReplyPresentation_1 = require("./inventoryReplyPresentation");
const INVENTORY_TRACE_EVIDENCE_ROW_LIMIT = 3;
function cleanupInventoryRequestedParty(value) { function cleanupInventoryRequestedParty(value) {
const cleaned = String(value ?? "") const cleaned = String(value ?? "")
.replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|покупател|buyer|customer|item|product|sku)[\s\S]*$/iu, "") .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|покупател|buyer|customer|item|product|sku)[\s\S]*$/iu, "")
@ -236,7 +237,10 @@ function composeInventoryReply(intent, rows, options, deps) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
} }
if (summary.documents.length > 0) { 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)); 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 itemLabel = requestedItemHint || (summary.item ?? "товар не определен");
const excludedCounterpartyTokens = [itemLabel]; const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine = summary.counterparties.length === 1 const directAnswerLine = summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` ? `По номенклатуре ${itemLabel} в документах выбытия покупатель: ${summary.counterparties[0]}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает, что продали именно выбранный остаток/лот.`
: summary.counterparties.length > 1 : summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` ? `По номенклатуре ${itemLabel} в документах выбытия найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает связь с конкретным остатком/лотом.`
: `По товару ${itemLabel} покупатель в доступных данных не выделен.`; : `По товару ${itemLabel} покупатель в доступных данных не выделен.`;
const lines = [directAnswerLine, "", "Подтверждение:"]; const lines = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); 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(summary.documents.length)}.`);
lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`); lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`);
if (summary.counterparties.length === 1) { if (summary.counterparties.length === 1) {
lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); lines.push(`- По доступным движениям номенклатура отгружалась покупателю: ${summary.counterparties[0]}.`);
} }
else if (summary.counterparties.length > 1) { 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) { else if (saleRows.length > 0) {
lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных."); lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных.");
} }
if (saleRows.length > 0) {
lines.push("- Без партионного учета этот ответ подтверждает продажи по номенклатуре, а не юридически точную продажу конкретного остатка или партии.");
}
lines.push("", "Документы выбытия:"); lines.push("", "Документы выбытия:");
if (saleRows.length > 0) { 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 { else {
lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных."); lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных.");

View File

@ -415,6 +415,7 @@ function sameCounterpartyCandidate(left, right) {
function readGroundedDiscoveryCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) { function readGroundedDiscoveryCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) {
const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString); const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString);
const suppressDiscoveryEntityCarryover = discoveryPilotScope === "metadata_inspection_v1" || const suppressDiscoveryEntityCarryover = discoveryPilotScope === "metadata_inspection_v1" ||
readAssistantMcpDiscoveryTurnMeaning(debug)?.stale_replay_forbidden === true ||
readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug); readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug);
if (suppressDiscoveryEntityCarryover) { if (suppressDiscoveryEntityCarryover) {
return null; return null;

View File

@ -943,7 +943,8 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
} }
if (rankingNeed) { if (rankingNeed) {
const incomingLeader = strongestIncomingYear(overview); const incomingLeader = strongestIncomingYear(overview);
const netLeader = strongestNetYear(overview); const canRankYearlyNet = !limitLine;
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
const leaderYear = toNonEmptyString(incomingLeader?.year_bucket); const leaderYear = toNonEmptyString(incomingLeader?.year_bucket);
const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru); const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru);
const leaderRows = Number(incomingLeader?.incoming_rows_with_amount); const leaderRows = Number(incomingLeader?.incoming_rows_with_amount);
@ -964,7 +965,10 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
if (requestedFinancialBoundaryLine) { if (requestedFinancialBoundaryLine) {
lines.push(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) { if (yearRows) {
lines.push(yearRows); lines.push(yearRows);
} }

View File

@ -7,6 +7,12 @@ function createAssistantTransitionPolicy(deps) {
function normalizeFollowupText(value) { function normalizeFollowupText(value) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); 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) { function hasBankOperationsPivotCue(text) {
const normalized = normalizeFollowupText(text); const normalized = normalizeFollowupText(text);
if (!normalized) { if (!normalized) {
@ -955,7 +961,21 @@ function createAssistantTransitionPolicy(deps) {
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate)); 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); const carryoverTargetIntent = (0, assistantContinuityPolicy_1.resolveFollowupTargetIntent)(inventoryPurchaseDateVatBridge, selectedObjectRetargetIntent, explicitIntentForCarryover, sourceIntent, followupSelectionMode, deps.toNonEmptyString(inventoryRootFrame?.intent), displayedEntityTargetIntent, previousIntent, explicitInventorySameDatePivot);
return { return {
followupContext: { followupContext: {

View File

@ -894,6 +894,41 @@ function isBroadDebtPolarityQuestion(intent: AddressIntent, text: string): boole
); );
} }
function isShortDebtRoleMirrorFollowup(
intent: AddressIntent,
text: string,
followupContext: AddressFollowupContext | null
): boolean {
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( function mergeFollowupFilters(
current: AddressFilterSet, current: AddressFilterSet,
intent: AddressIntent, intent: AddressIntent,
@ -1104,8 +1139,13 @@ function mergeFollowupFilters(
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null); (followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
const currentCounterparty = toNonEmptyString(merged.counterparty); const currentCounterparty = toNonEmptyString(merged.counterparty);
const suppressCounterpartyForBroadDebtQuestion = isBroadDebtPolarityQuestion(intent, userMessage) && !currentCounterparty; const suppressCounterpartyForBroadDebtQuestion = isBroadDebtPolarityQuestion(intent, userMessage) && !currentCounterparty;
const suppressCounterpartyForShortDebtMirror =
isShortDebtRoleMirrorFollowup(intent, userMessage, followupContext) &&
followupContext.previous_anchor_type !== "counterparty" &&
(!currentCounterparty || isLowQualityCounterpartyAnchor(currentCounterparty));
const shouldInheritCounterparty = const shouldInheritCounterparty =
!suppressCounterpartyForBroadDebtQuestion && !suppressCounterpartyForBroadDebtQuestion &&
!suppressCounterpartyForShortDebtMirror &&
(!currentCounterparty || (!currentCounterparty ||
(Boolean(inheritedCounterparty) && (Boolean(inheritedCounterparty) &&
isLowQualityCounterpartyAnchor(currentCounterparty) && isLowQualityCounterpartyAnchor(currentCounterparty) &&
@ -1113,6 +1153,9 @@ function mergeFollowupFilters(
if (inheritedCounterparty && suppressCounterpartyForBroadDebtQuestion) { if (inheritedCounterparty && suppressCounterpartyForBroadDebtQuestion) {
reasons.push("counterparty_carryover_suppressed_for_broad_debt_polarity_question"); 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) { if (inheritedCounterparty && shouldInheritCounterparty) {
merged.counterparty = inheritedCounterparty; merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");

View File

@ -43,6 +43,8 @@ interface InventoryTraceSummary {
totalAmount: number; totalAmount: number;
} }
const INVENTORY_TRACE_EVIDENCE_ROW_LIMIT = 3;
interface InventoryAgingByItemAggregate { interface InventoryAgingByItemAggregate {
item: string; item: string;
warehouse: string | null; warehouse: string | null;
@ -341,7 +343,16 @@ export function composeInventoryReply(
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
} }
if (summary.documents.length > 0) { if (summary.documents.length > 0) {
appendInventorySection(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 8)); 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 buildFactualSummaryReply( return buildFactualSummaryReply(
lines, lines,
@ -486,9 +497,9 @@ export function composeInventoryReply(
const excludedCounterpartyTokens = [itemLabel]; const excludedCounterpartyTokens = [itemLabel];
const directAnswerLine = const directAnswerLine =
summary.counterparties.length === 1 summary.counterparties.length === 1
? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` ? `По номенклатуре ${itemLabel} в документах выбытия покупатель: ${summary.counterparties[0]}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает, что продали именно выбранный остаток/лот.`
: summary.counterparties.length > 1 : summary.counterparties.length > 1
? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` ? `По номенклатуре ${itemLabel} в документах выбытия найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает связь с конкретным остатком/лотом.`
: `По товару ${itemLabel} покупатель в доступных данных не выделен.`; : `По товару ${itemLabel} покупатель в доступных данных не выделен.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`);
@ -496,15 +507,27 @@ export function composeInventoryReply(
lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`); lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`); lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`);
if (summary.counterparties.length === 1) { if (summary.counterparties.length === 1) {
lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); lines.push(`- По доступным движениям номенклатура отгружалась покупателю: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) { } 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) { } else if (saleRows.length > 0) {
lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных."); lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных.");
} }
if (saleRows.length > 0) {
lines.push(
"- Без партионного учета этот ответ подтверждает продажи по номенклатуре, а не юридически точную продажу конкретного остатка или партии."
);
}
lines.push("", "Документы выбытия:"); lines.push("", "Документы выбытия:");
if (saleRows.length > 0) { 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 { } else {
lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных."); lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных.");
} }

View File

@ -634,6 +634,7 @@ function readGroundedDiscoveryCounterparty(
const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString); const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString);
const suppressDiscoveryEntityCarryover = const suppressDiscoveryEntityCarryover =
discoveryPilotScope === "metadata_inspection_v1" || discoveryPilotScope === "metadata_inspection_v1" ||
readAssistantMcpDiscoveryTurnMeaning(debug)?.stale_replay_forbidden === true ||
readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug); readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug);
if (suppressDiscoveryEntityCarryover) { if (suppressDiscoveryEntityCarryover) {
return null; return null;

View File

@ -1129,7 +1129,8 @@ function buildCompactBusinessOverviewReply(
if (rankingNeed) { if (rankingNeed) {
const incomingLeader = strongestIncomingYear(overview); const incomingLeader = strongestIncomingYear(overview);
const netLeader = strongestNetYear(overview); const canRankYearlyNet = !limitLine;
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
const leaderYear = toNonEmptyString(incomingLeader?.year_bucket); const leaderYear = toNonEmptyString(incomingLeader?.year_bucket);
const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru); const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru);
const leaderRows = Number(incomingLeader?.incoming_rows_with_amount); const leaderRows = Number(incomingLeader?.incoming_rows_with_amount);
@ -1152,7 +1153,12 @@ function buildCompactBusinessOverviewReply(
if (requestedFinancialBoundaryLine) { if (requestedFinancialBoundaryLine) {
lines.push(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) { if (yearRows) {
lines.push(yearRows); lines.push(yearRows);
} }

View File

@ -47,6 +47,18 @@ export function createAssistantTransitionPolicy(deps) {
return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); 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) { function hasBankOperationsPivotCue(text) {
const normalized = normalizeFollowupText(text); const normalized = normalizeFollowupText(text);
if (!normalized) { if (!normalized) {
@ -1332,7 +1344,24 @@ export function createAssistantTransitionPolicy(deps) {
hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate) 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 = resolveFollowupTargetIntent( const carryoverTargetIntent = resolveFollowupTargetIntent(
inventoryPurchaseDateVatBridge, inventoryPurchaseDateVatBridge,
selectedObjectRetargetIntent, selectedObjectRetargetIntent,

View File

@ -119,6 +119,51 @@ describe("address follow-up temporal regressions", () => {
expect(result?.filters.extracted_filters.counterparty).toBeUndefined(); expect(result?.filters.extracted_filters.counterparty).toBeUndefined();
}); });
it("suppresses stale counterparty filters from organization-scoped short debt mirror follow-up", () => {
const result = runAddressDecomposeStage("а нам?", {
previous_intent: "payables_confirmed_as_of_date",
target_intent: "receivables_confirmed_as_of_date",
previous_filters: {
as_of_date: "2026-05-17",
organization: "ООО Альтернатива Плюс",
counterparty: "нас самый доходный клиент"
},
previous_anchor_type: "organization",
previous_anchor_value: "ООО Альтернатива Плюс"
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2026-05-17");
expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс");
expect(result?.filters.extracted_filters.counterparty).toBeUndefined();
expect(result?.baseReasons).toContain("counterparty_carryover_suppressed_for_short_debt_mirror");
expect(result?.baseReasons).not.toContain("counterparty_from_followup_context");
});
it("keeps resolved counterparty focus for short debt mirror follow-up", () => {
const result = runAddressDecomposeStage("а нам?", {
previous_intent: "payables_confirmed_as_of_date",
target_intent: "receivables_confirmed_as_of_date",
previous_filters: {
as_of_date: "2026-05-17",
organization: "ООО Альтернатива Плюс",
counterparty: "Группа СВК"
},
previous_anchor_type: "counterparty",
previous_anchor_value: "Группа СВК",
resolved_counterparty_from_display: true
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2026-05-17");
expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс");
expect(result?.filters.extracted_filters.counterparty).toBe("Группа СВК");
expect(result?.baseReasons).toContain("counterparty_from_followup_context");
expect(result?.baseReasons).not.toContain("counterparty_carryover_suppressed_for_short_debt_mirror");
});
it("keeps same-date inventory pivot anchored to the previous VAT date", () => { it("keeps same-date inventory pivot anchored to the previous VAT date", () => {
const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", { const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", {
previous_intent: "vat_payable_confirmed_as_of_date", previous_intent: "vat_payable_confirmed_as_of_date",

View File

@ -703,6 +703,7 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-05-31"); expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-05-31");
expect(result?.debug.reasons).toContain("inventory_selected_object_sale_trace_signal_detected"); expect(result?.debug.reasons).toContain("inventory_selected_object_sale_trace_signal_detected");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ООО \\Ромашка\\"); expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ООО \\Ромашка\\");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("без партионного учета не доказывает");
expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); expect(String(result?.reply_text ?? "")).toContain("Документы выбытия");
}); });
@ -750,6 +751,8 @@ describe("inventory selected-object follow-up", () => {
expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м"); expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\");
expect(String(result?.reply_text ?? "")).not.toContain("покупатель определен");
expect(String(result?.reply_text ?? "")).toContain("продажный след по номенклатуре");
}); });
it("detaches snapshot date from execution query during sale-trace history recovery", async () => { it("detaches snapshot date from execution query during sale-trace history recovery", async () => {

View File

@ -479,6 +479,42 @@ describe("assistantContinuityPolicy organization authority", () => {
}); });
}); });
it("does not carry MCP discovery counterparty from a stale replay-forbidden turn", () => {
const filters = resolveAddressDebugCarryoverFilters({
detected_intent: "payables_confirmed_as_of_date",
extracted_filters: {
organization: "Org Alt",
as_of_date: "2026-05-17"
},
mcp_discovery_response_applied: true,
assistant_mcp_discovery_entry_point_v1: {
entry_status: "bridge_executed",
turn_input: {
turn_meaning_ref: {
explicit_entity_candidates: ["old ranking counterparty"],
metadata_scope_hint: "old ranking counterparty",
stale_replay_forbidden: true
}
},
bridge: {
bridge_status: "answer_draft_ready",
business_fact_answer_allowed: true,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
},
pilot: {
pilot_scope: "counterparty_value_flow_query_movements_v1"
}
}
}
});
expect(filters).toEqual({
organization: "Org Alt",
as_of_date: "2026-05-17"
});
});
it("hydrates inventory root-frame state from navigation scope and preserves derived current frame kind", () => { it("hydrates inventory root-frame state from navigation scope and preserves derived current frame kind", () => {
const state = hydrateInventoryRootFrameState( const state = hydrateInventoryRootFrameState(
{ {

View File

@ -445,9 +445,12 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).toContain("нельзя автоматически читать как обычного клиента или поставщика"); expect(candidate.reply_text).toContain("нельзя автоматически читать как обычного клиента или поставщика");
expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности"); expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности");
expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль"); expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль");
expect(candidate.reply_text).toContain("Годовое операционное нетто в широком срезе не ранжирую");
expect(candidate.reply_text).toContain("проверка достигла лимита строк"); expect(candidate.reply_text).toContain("проверка достигла лимита строк");
expect(candidate.reply_text).toContain("выбрать конкретный год или квартал для дозапроса"); expect(candidate.reply_text).toContain("выбрать конкретный год или квартал для дозапроса");
expect(candidate.reply_text).toContain("без выдачи непроверенного итога"); expect(candidate.reply_text).toContain("без выдачи непроверенного итога");
expect(candidate.reply_text).not.toContain("По расчетному операционному нетто лучший год");
expect(candidate.reply_text).not.toContain("По годам:");
expect(candidate.reply_text).not.toContain("лимит выборки MCP"); expect(candidate.reply_text).not.toContain("лимит выборки MCP");
expect(candidate.reply_text).not.toContain("MCP-срез"); expect(candidate.reply_text).not.toContain("MCP-срез");
expect(candidate.reply_text).not.toContain("Что подтверждено:"); expect(candidate.reply_text).not.toContain("Что подтверждено:");

View File

@ -657,8 +657,8 @@ describe("assistant MCP discovery runtime bridge", () => {
expect(overview?.accounting_financial_result?.net_margin_to_revenue_pct).toBe(-59.41); expect(overview?.accounting_financial_result?.net_margin_to_revenue_pct).toBe(-59.41);
expect(missingFamilies).not.toContain("accounting_profit_margin"); expect(missingFamilies).not.toContain("accounting_profit_margin");
expect(userFacing).toContain("90/91/99"); expect(userFacing).toContain("90/91/99");
expect(userFacing).toContain("90.01");
expect(userFacing).toContain("7 136 815,85"); expect(userFacing).toContain("7 136 815,85");
expect(userFacing).not.toContain("90.01");
expect(userFacing).not.toContain("operating-flow/trading-margin proxy"); expect(userFacing).not.toContain("operating-flow/trading-margin proxy");
expect(result.reason_codes).toContain("pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows");
expect(result.reason_codes).toContain("answer_contains_business_overview_accounting_financial_result"); expect(result.reason_codes).toContain("answer_contains_business_overview_accounting_financial_result");
@ -850,7 +850,7 @@ describe("assistant MCP discovery runtime bridge", () => {
evidence_status: "financial_institution_leads_outgoing_cash" evidence_status: "financial_institution_leads_outgoing_cash"
}); });
expect(missingFamilies).not.toContain("vendor_risk_procurement_quality"); expect(missingFamilies).not.toContain("vendor_risk_procurement_quality");
expect(userFacing).toContain("procurement-concentration route"); expect(userFacing).not.toContain("procurement-concentration route");
expect(userFacing).toContain("банк/финансовая организация"); expect(userFacing).toContain("банк/финансовая организация");
expect(userFacing).toContain("Поставщик А"); expect(userFacing).toContain("Поставщик А");
expect(userFacing).toContain("Надежность поставщиков"); expect(userFacing).toContain("Надежность поставщиков");

View File

@ -1861,6 +1861,62 @@ describe("assistantTransitionPolicy", () => {
period_to: "2017-05-31" period_to: "2017-05-31"
}); });
}); });
it("retargets same-period VAT payable follow-up away from current-date payable snapshots", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
text: "Receivables as of 2017-05-31 were collected for May.",
debug: {
detected_intent: "receivables_confirmed_as_of_date",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2017-05-31",
period_from: "2017-05-01",
period_to: "2017-05-31"
},
anchor_type: "organization",
anchor_value_resolved: 'ООО "Альтернатива Плюс"'
}
}),
hasAddressFollowupContextSignal: () => true,
hasReferentialPointer: () => true,
resolveAddressIntent: () => ({ intent: "unknown" }),
resolveAddressIntentFamily: (intent: unknown) => {
if (String(intent ?? "").startsWith("receivables_")) return "receivables";
if (String(intent ?? "").startsWith("vat_")) return "vat";
return null;
},
resolveAssistantTurnMeaning: () => ({
schema_version: "assistant_turn_meaning_v1",
asked_domain_family: "vat",
asked_action_family: "confirmed_snapshot",
explicit_intent_candidate: "vat_payable_confirmed_as_of_date",
explicit_entity_candidates: [],
intent_override_strength: "explicit_current_turn_intent",
stale_replay_forbidden: false
})
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
"а какой ндс мы должны примерно заплатить за этот период?",
[],
"Какой НДС мы должны заплатить за текущий период?",
{
predecomposeContract: {
intent: "vat_payable_confirmed_as_of_date"
}
},
null
);
expect(carryover?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date");
expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(carryover?.followupContext?.previous_filters).toMatchObject({
organization: 'ООО "Альтернатива Плюс"',
period_from: "2017-05-01",
period_to: "2017-05-31"
});
});
it("carries metadata-scoped subjectless loop state through follow-up context", () => { it("carries metadata-scoped subjectless loop state through follow-up context", () => {
const policy = buildPolicy({ const policy = buildPolicy({
findLastAddressAssistantItem: () => ({ findLastAddressAssistantItem: () => ({