АРЧ - Склад: сохранять полный item-anchor в selected-object sale trace и закрепить buyer follow-up регрессиями

This commit is contained in:
2026-04-15 17:57:05 +03:00
parent 7a6d8eb070
commit f911f9893b
27 changed files with 1539 additions and 180 deletions
@@ -4065,6 +4065,7 @@ export function composeFactualReply(
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null;
const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage);
if (purchaseDateActionFocus) {
const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod);
@@ -4074,7 +4075,9 @@ export function composeFactualReply(
? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.`
: summary.firstPeriod === summary.lastPeriod
? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`
: `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`;
const lines: string[] = [directAnswerLine];
if (purchaseRows.length > 0) {
lines.push("", "Подтверждение:");
@@ -4090,8 +4093,13 @@ export function composeFactualReply(
if (summary.documents.length > 0) {
lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`);
}
if (summary.firstPeriod && asOfDate && summary.firstPeriod < asOfDate) {
lines.push(`- Дата вопроса по остатку: ${formatDateRu(asOfDate)}; дата закупки показана по подтвержденному закупочному следу.`);
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
}
return {
@@ -4105,35 +4113,50 @@ export function composeFactualReply(
};
}
const directAnswerLine =
summary.counterparties.length === 1
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
purchaseRows.length <= 0
? boundedAsOfLabel
? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.`
: `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.`
: summary.counterparties.length === 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.`
: boundedAsOfLabel
? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`
: `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- По доступным закупочным движениям товар связан с поставщиком: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- По доступным закупочным движениям найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`);
} else if (purchaseRows.length > 0) {
lines.push("- Закупочные документы найдены, но поставщик не материализован отдельным полем в текущем exact-контуре.");
if (purchaseRows.length > 0) {
lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`);
lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`);
lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`);
lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`);
if (summary.counterparties.length === 1) {
lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`);
} else if (summary.counterparties.length > 1) {
lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`);
} else {
lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем.");
}
if (boundedAsOfLabel) {
lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.counterparties.length > 1) {
lines.push(
`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`
);
}
} else if (boundedAsOfLabel) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
}
if (summary.documents.length > 0) {
lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8));
}
if (purchaseRows.length > 0) {
lines.push(
"",
"Сервисно:",
"- Без партионности этот контур показывает документально наблюдаемый закупочный след, а не лот-level происхождение текущего остатка."
);
}
return {
responseType: purchaseRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY",
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
@@ -9,7 +9,13 @@
import { detectAddressQuestionMode } from "../addressQueryClassifier";
import { classifyAddressQueryShape } from "../addressQueryShapeClassifier";
import { resolveAddressIntent } from "../addressIntentResolver";
import { extractAddressFilters } from "../addressFilterExtractor";
import {
extractSelectedObjectQuotedValue,
extractAddressFilters,
isInventoryItemAnchorDegradation,
isLowQualityInventoryItemAnchorValue
} from "../addressFilterExtractor";
import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "../inventoryLifecycleCueHelpers";
import { applyAddressLlmSemanticHintsToExtraction } from "./semanticHintOverlay";
import type { AddressLlmSemanticHints } from "../../types/addressQuery";
@@ -511,25 +517,24 @@ function hasSelectedObjectInventorySignal(text: string): boolean {
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
}
function hasInventorySupplierFollowupCue(text: string): boolean {
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(
String(text ?? "")
);
export function hasInventorySupplierFollowupCue(text: string): boolean {
return hasInventorySupplierCue(String(text ?? ""));
}
function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
String(text ?? "")
);
}
function hasInventoryPurchaseDateFollowupCue(text: string): boolean {
export function hasInventoryPurchaseDateFollowupCue(text: string): boolean {
const value = String(text ?? "");
return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(
String(text ?? "")
);
value
) || (/когда/iu.test(value) && hasInventoryPurchaseStem(value));
}
function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
export function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
const normalized = String(text ?? "").trim().toLowerCase();
if (!normalized) {
return false;
@@ -537,13 +542,11 @@ function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean {
return /(?:^|\s)(?:когда|а\s+когда|ну\s+когда)(?=$|[\s,.;:!?])/iu.test(normalized) && normalized.split(/\s+/).filter(Boolean).length <= 3;
}
function hasInventorySaleFollowupCue(text: string): boolean {
return /(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|кому\s+дальше\s+продали|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил|buyer|покупател)/iu.test(
String(text ?? "")
);
export function hasInventorySaleFollowupCue(text: string): boolean {
return hasInventorySaleCue(String(text ?? ""));
}
function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolean {
export function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolean {
return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(
String(text ?? "")
);
@@ -777,13 +780,38 @@ function mergeFollowupFilters(
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") &&
!toNonEmptyString(merged.item)
intent === "inventory_aging_by_purchase_date")
) {
const inheritedItem = previousItem ?? previousAnchorItem;
if (inheritedItem && intent !== "inventory_aging_by_purchase_date") {
const explicitQuotedItem = toNonEmptyString(extractSelectedObjectQuotedValue(userMessage));
const currentItem = toNonEmptyString(merged.item);
const shouldAdoptExplicitQuotedItem =
Boolean(explicitQuotedItem) &&
(!currentItem ||
currentItem !== explicitQuotedItem ||
isInventoryItemAnchorDegradation(explicitQuotedItem ?? "", currentItem ?? ""));
if (explicitQuotedItem && shouldAdoptExplicitQuotedItem) {
merged.item = explicitQuotedItem;
reasons.push(currentItem ? "item_replaced_from_explicit_quote" : "item_from_explicit_quote");
}
const effectiveCurrentItem = toNonEmptyString(merged.item);
const hasExplicitDifferentQuotedItem =
Boolean(explicitQuotedItem) &&
Boolean(inheritedItem) &&
explicitQuotedItem !== inheritedItem;
const shouldInheritItem =
Boolean(inheritedItem) &&
intent !== "inventory_aging_by_purchase_date" &&
!hasExplicitDifferentQuotedItem &&
(!effectiveCurrentItem ||
(isLowQualityInventoryItemAnchorValue(effectiveCurrentItem) &&
!isLowQualityInventoryItemAnchorValue(inheritedItem ?? "")) ||
(effectiveCurrentItem &&
inheritedItem &&
isInventoryItemAnchorDegradation(inheritedItem, effectiveCurrentItem)));
if (shouldInheritItem && inheritedItem) {
merged.item = inheritedItem;
reasons.push("item_from_followup_context");
reasons.push(effectiveCurrentItem ? "item_replaced_from_followup_context" : "item_from_followup_context");
}
}
if (sameDateRequested) {
@@ -4,6 +4,7 @@ import type {
AddressLlmSemanticHints,
AddressSemanticFrame
} from "../../types/addressQuery";
import { isInventoryItemAnchorDegradation, isLowQualityInventoryItemAnchorValue } from "../addressFilterExtractor";
function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) {
@@ -83,6 +84,31 @@ function applyDateScopeHint(frame: AddressSemanticFrame, dateScopeKind: AddressL
}
}
function normalizeInventoryItemAnchorValue(value: string): string {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function shouldApplyInventoryItemSemanticHint(currentItemValue: string | null, hintedItemValue: string): boolean {
if (!hintedItemValue || isLowQualityInventoryItemAnchorValue(hintedItemValue)) {
return false;
}
if (!currentItemValue || isLowQualityInventoryItemAnchorValue(currentItemValue)) {
return true;
}
if (isInventoryItemAnchorDegradation(currentItemValue, hintedItemValue)) {
return false;
}
const currentNormalized = normalizeInventoryItemAnchorValue(currentItemValue);
const hintedNormalized = normalizeInventoryItemAnchorValue(hintedItemValue);
if (currentNormalized === hintedNormalized) {
return false;
}
return hintedNormalized.includes(currentNormalized);
}
export function applyAddressLlmSemanticHintsToExtraction(
extraction: AddressFilterExtraction,
semanticHintsInput: unknown
@@ -152,11 +178,16 @@ export function applyAddressLlmSemanticHintsToExtraction(
}
if (semanticHints.scope_target_kind === "item" && scopeTargetText) {
extractedFilters.item = scopeTargetText;
pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "item";
semanticFrame.anchor_value = scopeTargetText;
const currentItemValue = toNonEmptyString(extractedFilters.item);
if (shouldApplyInventoryItemSemanticHint(currentItemValue, scopeTargetText)) {
extractedFilters.item = scopeTargetText;
pushWarning(warnings, "item_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "item";
semanticFrame.anchor_value = scopeTargetText;
} else if (currentItemValue && currentItemValue !== scopeTargetText) {
pushWarning(warnings, "item_llm_semantics_ignored");
}
}
return {