АРЧ - Склад: сохранять полный 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
@@ -943,7 +943,7 @@ function usesRecipeDefaultLimit(intent: AddressIntent): boolean {
);
}
function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
export function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
const value = cleanupAnchorValue(rawValue)
.trim()
.toLowerCase()
@@ -959,16 +959,47 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
return true;
}
const lowQualityTokens = new Set([
"в",
"во",
"на",
"по",
"у",
"от",
"из",
"для",
"и",
"или",
"это",
"этот",
"эту",
"его",
"ее",
"её",
"итог",
"итоге",
"итогу",
"сейчас",
"лежат",
"лежит",
"лежали",
"был",
"была",
"было",
"были",
"куплен",
"куплена",
"куплены",
"куплено",
"продан",
"продана",
"проданы",
"продано",
"продали",
"реализован",
"реализована",
"реализованы",
"реализовано",
"реализовали",
"документам",
"документами",
"документы",
@@ -989,6 +1020,42 @@ function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean {
return meaningfulTokens.length === 0;
}
function normalizeInventoryItemAnchorForComparison(rawValue: string): string {
return cleanupAnchorValue(rawValue)
.trim()
.toLowerCase()
.replace(/С‘/g, "Рµ")
.replace(/\s+/g, " ");
}
function tokenizeInventoryItemAnchorForComparison(rawValue: string): string[] {
return normalizeInventoryItemAnchorForComparison(rawValue)
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
.filter(Boolean);
}
export function isInventoryItemAnchorDegradation(sourceValue: string, candidateValue: string): boolean {
const sourceNormalized = normalizeInventoryItemAnchorForComparison(sourceValue);
const candidateNormalized = normalizeInventoryItemAnchorForComparison(candidateValue);
if (!sourceNormalized || !candidateNormalized || sourceNormalized === candidateNormalized) {
return false;
}
if (isLowQualityInventoryItemAnchorValue(candidateNormalized)) {
return true;
}
if (sourceNormalized.includes(candidateNormalized) && candidateNormalized.length < sourceNormalized.length) {
return true;
}
const sourceTokens = tokenizeInventoryItemAnchorForComparison(sourceNormalized);
const candidateTokens = tokenizeInventoryItemAnchorForComparison(candidateNormalized);
return (
candidateTokens.length > 0 &&
candidateTokens.length < sourceTokens.length &&
candidateTokens.every((token) => sourceTokens.includes(token))
);
}
function cleanupInventoryItemAnchorValue(value: string): string {
return String(value ?? "")
.replace(/^['"«»`]+|['"«»`]+$/gu, "")
@@ -1012,7 +1079,34 @@ function trimInventoryItemAnchorTail(rawValue: string): string {
return cleanupInventoryItemAnchorValue(value);
}
function extractSelectedObjectQuotedValue(text: string): string | undefined {
function extractQuotedAnchorValue(text: string): string | undefined {
const patterns = [/[«"]([^«»"\r\n]+)[»"]/u, /'([^'\r\n]+)'/u];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
if (candidate) {
return candidate;
}
}
return undefined;
}
function extractUnicodeQuotedAnchorValue(text: string): string | undefined {
const patterns = [
/(?:\u00AB|\u0412\u00AB|")([^\u00AB\u00BB"\r\n]+?)(?:\u00BB|\u0412\u00BB|")/u,
/'([^'\r\n]+)'/u
];
for (const pattern of patterns) {
const match = String(text ?? "").match(pattern);
const candidate = cleanupInventoryItemAnchorValue(String(match?.[1] ?? ""));
if (candidate) {
return candidate;
}
}
return undefined;
}
export function extractSelectedObjectQuotedValue(text: string): string | undefined {
const patterns = [
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*[«"]([^»"\r\n]+)[»"]/iu,
/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)\s*:\s*[«"]([^»"\r\n]+)[»"]/iu
@@ -1024,7 +1118,7 @@ function extractSelectedObjectQuotedValue(text: string): string | undefined {
return candidate;
}
}
return undefined;
return extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
}
function extractInventoryItemFromSelectedObject(text: string): string | undefined {
@@ -1051,6 +1145,10 @@ function extractInventoryItemAnchor(text: string): string | undefined {
if (selectedObjectItem) {
return selectedObjectItem;
}
const quotedItem = extractUnicodeQuotedAnchorValue(text) ?? extractQuotedAnchorValue(text);
if (quotedItem && !isLowQualityInventoryItemAnchorValue(quotedItem)) {
return quotedItem;
}
const patterns = [
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s*[«"']([^«»"'?\r\n]+)[»"'](?=$|[\s,.;:!?])/iu,
/(?:товар(?:а|у|ом|ы)?|номенклатур(?:а|у|ы)|позици(?:я|ю|и)|item|product|sku)\s+([^\r\n,.;:!?]+?)(?=\s+(?:на|по|у|от|из|для|и|когда|через|сейчас|еще|ещё|котор|которые|который|покупателю|поставщика|поставщику|за|в)\b|[:?]|$)/iu
@@ -1,4 +1,5 @@
import type { AddressIntentResolution } from "../types/addressQuery";
import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "./inventoryLifecycleCueHelpers";
const RECEIVABLES_STRONG = [
"кто должен нам",
@@ -1616,12 +1617,7 @@ function hasSelectedObjectInventoryCue(text: string): boolean {
}
function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean {
return (
hasSelectedObjectInventoryCue(text) &&
/(?:кто\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(
text
)
);
return hasSelectedObjectInventoryCue(text) && hasInventorySupplierCue(text);
}
function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolean {
@@ -1634,24 +1630,16 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea
}
function hasSelectedObjectInventorySaleTraceSignal(text: string): boolean {
return (
hasSelectedObjectInventoryCue(text) &&
/(?:кому\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали|кому\s+был\s+продан|куда\s+(?:в\s+итоге\s+)?(?:мы\s+)?продали(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|куда\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+купил|buyer|sale\s+trace|trace\s+of\s+sale)/iu.test(
text
)
);
return hasSelectedObjectInventoryCue(text) && hasInventorySaleCue(text);
}
function hasInventoryProvenanceSignalV2(text: string): boolean {
const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text);
const hasSupplierCue =
/(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(
text
);
const hasSupplierCue = hasInventorySupplierCue(text) || /кем\s+поставлен/iu.test(text);
const hasPurchaseCue =
/(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(
text
);
) || hasInventoryPurchaseStem(text);
return hasItemCue && hasSupplierCue && hasPurchaseCue;
}
@@ -1,5 +1,7 @@
import type { AddressModeDetection } from "../types/addressQuery";
import { hasInventoryPurchaseStem, hasInventorySaleCue, hasInventorySupplierCue } from "./inventoryLifecycleCueHelpers";
const ADDRESS_ACTION_TOKENS = [
"show",
"list",
@@ -286,8 +288,11 @@ function hasSelectedObjectInventoryFollowupSignal(text: string): boolean {
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
return false;
}
return /(?:у\s+кого\s+купили|у\s+кого\s+куплено|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(
text
return (
hasInventorySupplierCue(text) ||
hasInventorySaleCue(text) ||
/(?:кто\s+(?:поставил|продал)|по\s+каким\s+документам\s+.*купили)/iu.test(text) ||
(/\bкогда\b/iu.test(text) && hasInventoryPurchaseStem(text))
);
}
@@ -2008,8 +2008,6 @@ function shouldBoostAutoBroadenedLimit(intent: AddressIntent): boolean {
function shouldClearAsOfDateForHistoryRecovery(intent: AddressIntent): boolean {
return (
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain"
);
@@ -2020,8 +2018,6 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(
reasons: string[]
): boolean {
if (
intent !== "inventory_purchase_provenance_for_item" &&
intent !== "inventory_purchase_documents_for_item" &&
intent !== "inventory_sale_trace_for_item" &&
intent !== "inventory_purchase_to_sale_chain"
) {
@@ -47,6 +47,44 @@ __WHERE_CLAUSE__
Движения.Период __ORDER_DIRECTION__
`;
const INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"41.01" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.РеализацияТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Товары.Ссылка.Дата __ORDER_DIRECTION__
`;
const INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
Товары.Ссылка.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка) КАК Регистратор,
"41.01" КАК СчетДт,
"" КАК СчетКт,
Товары.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Товары.Номенклатура) КАК Номенклатура,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Контрагент) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.ДоговорКонтрагента) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Товары.Ссылка.Организация) КАК Организация,
Товары.Количество КАК Количество
ИЗ
Документ.ПоступлениеТоваровУслуг.Товары КАК Товары
__WHERE_CLAUSE__
УПОРЯДОЧИТЬ ПО
Товары.Ссылка.Дата __ORDER_DIRECTION__
`;
const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
@@ -972,17 +1010,6 @@ function toQueryStringLiteral(value: string): string {
return String(value ?? "").replace(/"/g, '""');
}
function buildOrganizationPresentationCondition(filters: AddressFilterSet, fieldPath: string): string | null {
const organization =
typeof filters.organization === "string" && filters.organization.trim().length > 0
? filters.organization.trim()
: "";
if (!organization) {
return null;
}
return `ПРЕДСТАВЛЕНИЕ(${fieldPath}) = "${toQueryStringLiteral(organization)}"`;
}
function buildWhereClause(filters: AddressFilterSet, fieldPath: string, extraConditions: string[] = []): string {
const periodFromExpr =
typeof filters.period_from === "string" && filters.period_from.trim().length > 0
@@ -1154,15 +1181,56 @@ function buildInventoryMovementQuery(
: side === "kt"
? creditPredicate
: `(${debitPredicate} ИЛИ ${creditPredicate})`;
const organizationCondition = buildOrganizationPresentationCondition(filters, "Движения.Организация");
return INVENTORY_MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", buildWhereClause(filters, "Движения.Период", [inventoryCondition]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryItemReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
const item = typeof filters.item === "string" ? filters.item.trim() : "";
if (!item) {
return null;
}
const escapedItem = toQueryStringLiteral(item);
const referenceSubquery =
`(ВЫБРАТЬ Номенклатура.Ссылка ИЗ Справочник.Номенклатура КАК Номенклатура ` +
`ГДЕ Номенклатура.Наименование = "${escapedItem}")`;
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => `${fieldPath} В ${referenceSubquery}`);
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace(
"__WHERE_CLAUSE__",
buildWhereClause(
filters,
"Движения.Период",
[inventoryCondition, organizationCondition].filter((item): item is string => Boolean(item))
"Товары.Ссылка.Дата",
['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item): item is string => Boolean(item))
)
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryPurchaseDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_PURCHASE_DOCUMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace(
"__WHERE_CLAUSE__",
buildWhereClause(
filters,
"Товары.Ссылка.Дата",
['Товары.Ссылка.Проведен = ИСТИНА', itemCondition].filter((item): item is string => Boolean(item))
)
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
@@ -1395,13 +1463,13 @@ export function buildAddressRecipePlan(
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "kt")
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "either")
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
@@ -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 {
@@ -11,6 +11,7 @@ import * as addressQueryService_1 from "./addressQueryService";
import * as addressQueryClassifier_1 from "./addressQueryClassifier";
import * as addressIntentResolver_1 from "./addressIntentResolver";
import * as addressFilterExtractor_1 from "./addressFilterExtractor";
import * as decomposeStage_1 from "./address_runtime/decomposeStage";
import * as predecomposeContract_1 from "./address_runtime/predecomposeContract";
import * as openaiResponsesClient_1 from "./openaiResponsesClient";
import * as addressMcpClient_1 from "./addressMcpClient";
@@ -2725,8 +2726,15 @@ function hasShortInventoryObjectFollowupSignal(userMessage) {
if (minTokens > 8) {
return false;
}
const hasDirectSaleFollowupCue = (sample) => /(?:кому|каму|куда)(?:\s+\S+){0,4}\s+(?:продали|продано|продан(?:о|а|ы)?|реализовали|реализован(?:о|а|ы)?)|(?:продали|продано|реализовали|реализован(?:о|а|ы)?)(?:\s+\S+){0,4}\s+(?:кому|каму|куда)|(?:^|\s)(?:продано|продали|реализовано|реализовали)(?=$|[\s,.;:!?])/iu.test(sample);
return samples.some((sample) => /^(?:кто|когда|документы|сумма|поставщик|покупатель)(?:\?)?$/iu.test(sample) ||
/^(?:когда\s+(?:примерно\s+)?купили(?:\s+ее)?|каким\s+документом|покажи\s+документы|по\s+каким\s+документам|все\s+закупки|все\s+поступления|кому\s+(?:мы\s+)?продали|кто\s+купил|цепочка|путь\s+товара)(?:\?)?$/iu.test(sample));
hasDirectSaleFollowupCue(sample) ||
(0, decomposeStage_1.hasInventorySupplierFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDocumentsFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseDateFollowupCue)(sample) ||
(0, decomposeStage_1.hasBareInventoryPurchaseDateFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventorySaleFollowupCue)(sample) ||
(0, decomposeStage_1.hasInventoryPurchaseToSaleChainFollowupCue)(sample));
}
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
@@ -3366,6 +3374,13 @@ function resolveRequiredAnchorTypeForIntent(intent) {
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
return "contract";
}
if (intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_purchase_to_sale_chain" ||
intent === "inventory_aging_by_purchase_date") {
return "item";
}
return null;
}
function evaluateAddressAnchorQuality(message) {
@@ -3383,7 +3398,9 @@ function evaluateAddressAnchorQuality(message) {
const extracted = (0, addressFilterExtractor_1.extractAddressFilters)(String(message ?? ""), intent);
const anchorValue = anchorType === "counterparty"
? toNonEmptyString(extracted?.extracted_filters?.counterparty)
: toNonEmptyString(extracted?.extracted_filters?.contract);
: anchorType === "contract"
? toNonEmptyString(extracted?.extracted_filters?.contract)
: toNonEmptyString(extracted?.extracted_filters?.item);
if (!anchorValue) {
return {
intent,
@@ -3394,7 +3411,9 @@ function evaluateAddressAnchorQuality(message) {
}
const lowQuality = anchorType === "counterparty"
? isLowQualityPredecomposeCounterpartyAnchor(anchorValue)
: isLowQualityPredecomposeContractAnchor(anchorValue);
: anchorType === "contract"
? isLowQualityPredecomposeContractAnchor(anchorValue)
: (0, addressFilterExtractor_1.isLowQualityInventoryItemAnchorValue)(anchorValue);
return {
intent,
anchorType,
@@ -3618,6 +3637,14 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ??
toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage));
const candidateSemanticItemAnchorValue = (((sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "item") ||
Boolean(sourceSelectedObjectItemAnchorValue)) &&
candidateMeta?.semanticHints?.scope_target_kind === "item"
? toNonEmptyString(candidateMeta.semanticHints.scope_target_text)
: null);
const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
@@ -3642,6 +3669,25 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const itemSemanticAnchorDegradedByCandidate = (sameIntentForAnchorSafety ||
Boolean(sourceSelectedObjectItemAnchorValue)) &&
Boolean(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue) &&
Boolean(candidateSemanticItemAnchorValue) &&
(0, addressFilterExtractor_1.isInventoryItemAnchorDegradation)(sourceSelectedObjectItemAnchorValue ?? sourceAnchorQuality.anchorValue ?? "", candidateSemanticItemAnchorValue ?? "");
if (itemSemanticAnchorDegradedByCandidate) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_anchor_degradation",
fallbackRuleHit: null,
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage);
}
const anchorDegradedByCandidate = sameIntentForAnchorSafety &&
sourceAnchorQuality.anchorType &&
sourceAnchorQuality.quality >= 2 &&
@@ -0,0 +1,40 @@
function toText(value: string): string {
return String(value ?? "");
}
export function hasInventoryPurchaseStem(text: string): boolean {
return /купл[а-яёa-z0-9_-]*/iu.test(toText(text));
}
export function hasInventorySupplierCue(text: string): boolean {
const value = toText(text);
if (
/(?:кто\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(
value
)
) {
return true;
}
return hasInventoryPurchaseStem(value) && /(?:у\s+кого|от\s+кого|где)/iu.test(value);
}
export function hasInventorySaleCue(text: string): boolean {
const value = toText(text);
if (/(?:buyer|покупател)/iu.test(value)) {
return true;
}
if (/(?:куда\s+ушла\s+позиция|куда\s+ушел\s+товар|кто\s+купил)/iu.test(value)) {
return true;
}
const hasDirectionCue = /(?:кому|каму|куда)/iu.test(value);
const hasSaleVerb =
/(?:продал(?:и|а|о|ы)?|продан(?:а|о|ы)?|продано|реализовал(?:и|а|о|ы)?|реализован(?:а|о|ы)?|реализовано)/iu.test(
value
);
if (hasDirectionCue && hasSaleVerb) {
return true;
}
return /(?:^|[\s,.;:!?])(продано|продали|продан(?:а|о|ы)?|реализовано|реализовали|реализован(?:а|о|ы)?)(?=$|[\s,.;:!?])/iu.test(
value
);
}