Закрепить складской provenance и supplier-overlap ассистента

This commit is contained in:
dctouch 2026-06-03 07:38:35 +03:00
parent 4d32b7fad5
commit 2cec57888a
28 changed files with 2274 additions and 162 deletions

View File

@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod }; return (mod && mod.__esModule) ? mod : { "default": mod };
}; };
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.isLowQualityCounterpartyAnchorValue = isLowQualityCounterpartyAnchorValue;
exports.isLowQualityInventoryItemAnchorValue = isLowQualityInventoryItemAnchorValue; exports.isLowQualityInventoryItemAnchorValue = isLowQualityInventoryItemAnchorValue;
exports.isInventoryItemAnchorDegradation = isInventoryItemAnchorDegradation; exports.isInventoryItemAnchorDegradation = isInventoryItemAnchorDegradation;
exports.extractSelectedObjectQuotedValue = extractSelectedObjectQuotedValue; exports.extractSelectedObjectQuotedValue = extractSelectedObjectQuotedValue;
@ -642,13 +643,17 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
const value = String(rawValue ?? "") const value = String(rawValue ?? "")
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/ё/g, "е"); .replace(/ё/g, "е")
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
if (!value) { if (!value) {
return true; return true;
} }
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) { if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
return true; return true;
} }
if (/^(?:товар(?:ы|а|ов|у|ом|ами|ах)?|номенклатур(?:а|ы|у|ой|ами)?|позици(?:я|и|ю|ей)|остат(?:ок|ки|ка|ков|кам|ками)?|склад|складе|складу)$/iu.test(value)) {
return true;
}
if (/^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test(value)) { if (/^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test(value)) {
return true; return true;
} }

View File

@ -1,6 +1,7 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.AddressQueryService = void 0; exports.AddressQueryService = void 0;
exports.ensureInventorySupplierOverlapLeadLine = ensureInventorySupplierOverlapLeadLine;
const config_1 = require("../config"); const config_1 = require("../config");
const addressRecipeCatalog_1 = require("./addressRecipeCatalog"); const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
const addressMcpClient_1 = require("./addressMcpClient"); const addressMcpClient_1 = require("./addressMcpClient");
@ -1988,6 +1989,10 @@ function hasExplicitPeriodWindow(filters) {
function asksForUnresolvedInventorySupplierLink(userMessage) { function asksForUnresolvedInventorySupplierLink(userMessage) {
return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? "")); return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? ""));
} }
function messagePairAsksForUnresolvedInventorySupplierLink(userMessage, rawUserMessage) {
return (asksForUnresolvedInventorySupplierLink(userMessage) ||
asksForUnresolvedInventorySupplierLink(rawUserMessage));
}
function canAutoBroadenPeriodWindow(intent, filters) { function canAutoBroadenPeriodWindow(intent, filters) {
if (Array.isArray(filters.warnings) && filters.warnings?.includes("exact_historical_period_window_requested")) { if (Array.isArray(filters.warnings) && filters.warnings?.includes("exact_historical_period_window_requested")) {
return false; return false;
@ -2037,7 +2042,11 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(intent, reasons) {
intent !== "inventory_purchase_to_sale_chain") { intent !== "inventory_purchase_to_sale_chain") {
return false; return false;
} }
return (reasons.includes("period_window_semantic_from_inventory_snapshot_context") || const historyIntent = intent === "inventory_sale_trace_for_item" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain";
return ((historyIntent && reasons.includes("as_of_date_from_analysis_context")) ||
reasons.includes("period_window_semantic_from_inventory_snapshot_context") ||
reasons.includes("period_window_semantic_from_inventory_as_of_month") || reasons.includes("period_window_semantic_from_inventory_as_of_month") ||
reasons.includes("as_of_date_from_followup_context") || reasons.includes("as_of_date_from_followup_context") ||
reasons.includes("period_from_followup_context") || reasons.includes("period_from_followup_context") ||
@ -2119,6 +2128,75 @@ function injectNoticeAfterLeadLine(text, notice) {
} }
return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n"); return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n");
} }
function injectNoticeBeforeLeadLine(text, notice) {
const normalizedText = typeof text === "string" ? text : "";
const normalizedNotice = typeof notice === "string" ? notice.trim() : "";
if (!normalizedText.trim()) {
return normalizedNotice;
}
if (!normalizedNotice) {
return normalizedText;
}
return [normalizedNotice, normalizedText].join("\n");
}
function injectAutoBroadenedPeriodNotice(intent, text, notice) {
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
return injectNoticeBeforeLeadLine(text, notice);
}
return injectNoticeAfterLeadLine(text, notice);
}
function extractSupplierOverlapReplyCounterparties(text) {
const counterparties = [];
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\n]+)/giu;
for (const match of String(text ?? "").matchAll(pattern)) {
const value = String(match[1] ?? "").replace(/\s+/gu, " ").trim();
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
continue;
}
counterparties.push(value);
}
return uniqueStrings(counterparties);
}
function formatSupplierOverlapCounterpartyLead(counterparties, limit = 6) {
const visible = counterparties.slice(0, limit);
const remaining = counterparties.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function hasInventorySupplierOverlapDefensiveLead(firstLine) {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized);
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized);
return startsWithStockScope && hasDefensiveAttribution;
}
function ensureInventorySupplierOverlapLeadLine(intent, text) {
if (intent !== "inventory_supplier_stock_overlap_as_of_date") {
return text;
}
const normalizedText = typeof text === "string" ? text : "";
const lines = normalizedText.split(/\r?\n/u);
const firstLine = lines[0]?.trim() ?? "";
const displayedCounterparties = extractSupplierOverlapReplyCounterparties(normalizedText);
if (displayedCounterparties.length > 0 &&
hasInventorySupplierOverlapDefensiveLead(firstLine)) {
const repairedLead = displayedCounterparties.length === 1
? `По складскому остатку найден поставщик закупочного следа: ${displayedCounterparties[0]}.`
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatSupplierOverlapCounterpartyLead(displayedCounterparties)}.`;
return [repairedLead, firstLine, ...lines.slice(1)].join("\n");
}
if (!normalizedText.trim() ||
/^По\s+(?:складскому\s+остатку|окну)\b/iu.test(firstLine) ||
/^В\s+текущем\s+складском\s+срезе\b/iu.test(firstLine)) {
return normalizedText;
}
if (!/^(?:Что\s+проверили:|Часть\s+закупочных\s+операций|Опорные\s+документы:)/iu.test(firstLine)) {
return normalizedText;
}
return [
"По складскому остатку однозначный поставщик текущего остатка не подтвержден; ниже показан проверенный закупочный след и ограничения.",
normalizedText
].join("\n");
}
function runtimeReadinessForLimitedCategory(category) { function runtimeReadinessForLimitedCategory(category) {
if (category === "empty_match" || category === "missing_anchor") { if (category === "empty_match" || category === "missing_anchor") {
return "LIVE_QUERYABLE_WITH_LIMITS"; return "LIVE_QUERYABLE_WITH_LIMITS";
@ -2971,7 +3049,7 @@ class AddressQueryService {
if (intent.intent === "inventory_supplier_stock_overlap_as_of_date" && if (intent.intent === "inventory_supplier_stock_overlap_as_of_date" &&
!toNonEmptyFilterValue(filters.extracted_filters.period_from) && !toNonEmptyFilterValue(filters.extracted_filters.period_from) &&
!toNonEmptyFilterValue(filters.extracted_filters.period_to) && !toNonEmptyFilterValue(filters.extracted_filters.period_to) &&
asksForUnresolvedInventorySupplierLink(userMessage) && messagePairAsksForUnresolvedInventorySupplierLink(userMessage, options.rawUserMessage) &&
!/(?:за\s+вс[её]\s+время|за\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)) { !/(?:за\s+вс[её]\s+время|за\s+любой\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)) {
const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date); const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date);
if (monthWindow) { if (monthWindow) {
@ -3079,6 +3157,19 @@ class AddressQueryService {
receivablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ??
vatPayableConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ??
filters.extracted_filters; filters.extracted_filters;
if (intent.intent === "inventory_aging_by_purchase_date" &&
toNonEmptyFilterValue(executionFilters.as_of_date) &&
(toNonEmptyFilterValue(executionFilters.period_from) || toNonEmptyFilterValue(executionFilters.period_to))) {
executionFilters = { ...executionFilters };
delete executionFilters.period_from;
delete executionFilters.period_to;
if (!filters.warnings.includes("period_window_detached_for_inventory_aging_execution")) {
filters.warnings.push("period_window_detached_for_inventory_aging_execution");
}
if (!baseReasons.includes("period_window_detached_for_inventory_aging_execution")) {
baseReasons.push("period_window_detached_for_inventory_aging_execution");
}
}
if (intent.intent === "counterparty_activity_lifecycle" && if (intent.intent === "counterparty_activity_lifecycle" &&
typeof executionFilters.counterparty === "string" && typeof executionFilters.counterparty === "string" &&
sameOrganizationEntityReference(executionFilters.counterparty, executionFilters.organization ?? activeOrganization)) { sameOrganizationEntityReference(executionFilters.counterparty, executionFilters.organization ?? activeOrganization)) {
@ -3233,6 +3324,7 @@ class AddressQueryService {
: typeof filterSet.account === "string" : typeof filterSet.account === "string"
? filterSet.account ? filterSet.account
: undefined, : undefined,
warehouseHint: typeof filterSet.warehouse === "string" ? filterSet.warehouse : undefined,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
@ -3861,7 +3953,7 @@ class AddressQueryService {
}); });
return { return {
handled: true, handled: true,
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters), reply_text: repairCounterpartyReplyLabel(ensureInventorySupplierOverlapLeadLine(intent.intent, replyText), intent.intent, filters.extracted_filters),
reply_type: (0, composeStage_1.inferReplyType)(responseType), reply_type: (0, composeStage_1.inferReplyType)(responseType),
response_type: responseType, response_type: responseType,
debug: debugPayload debug: debugPayload
@ -3983,7 +4075,7 @@ class AddressQueryService {
}); });
return { return {
handled: true, handled: true,
reply_text: repairCounterpartyReplyLabel(input.replyText, intent.intent, (input.extractedFilters ?? filters.extracted_filters)), reply_text: repairCounterpartyReplyLabel(ensureInventorySupplierOverlapLeadLine(intent.intent, input.replyText), intent.intent, (input.extractedFilters ?? filters.extracted_filters)),
reply_type: (0, composeStage_1.inferReplyType)(input.responseType), reply_type: (0, composeStage_1.inferReplyType)(input.responseType),
response_type: input.responseType, response_type: input.responseType,
debug: debugPayload debug: debugPayload
@ -4263,7 +4355,7 @@ class AddressQueryService {
resultMode: broadenedCoverageEvidence.result_mode ?? undefined resultMode: broadenedCoverageEvidence.result_mode ?? undefined
}); });
return buildFactualExecutionResult({ return buildFactualExecutionResult({
replyText: injectNoticeAfterLeadLine(broadenedFactual.text, broadenedPrefix), replyText: injectAutoBroadenedPeriodNotice(intent.intent, broadenedFactual.text, broadenedPrefix),
responseType: broadenedFactual.responseType, responseType: broadenedFactual.responseType,
responseSemantics: broadenedFactual.semantics, responseSemantics: broadenedFactual.semantics,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id, selectedRecipe: broadenedSelection.selected_recipe.recipe_id,

View File

@ -1441,20 +1441,11 @@ function buildInventoryAgingByPurchaseDateQuery(filters, resolvedLimit) {
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"]) buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
]; ];
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions); const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
const currentStockItemSubquery = [
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
" Остатки.Субконто1",
" ИЗ",
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
buildStaticWhereClause(onHandScopeConditions, " "),
")"
].join("\n");
const purchaseWhereClause = buildStaticWhereClause([ const purchaseWhereClause = buildStaticWhereClause([
"Товары.Ссылка.Проведен = ИСТИНА", "Товары.Ссылка.Проведен = ИСТИНА",
`Товары.Ссылка.Дата <= ${asOfExpr}`, `Товары.Ссылка.Дата <= ${asOfExpr}`,
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]), buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]), buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"])
`Товары.Номенклатура В ${currentStockItemSubquery}`
]); ]);
return ` return `
ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit} ВЫБРАТЬ ПЕРВЫЕ ${resolvedLimit}

View File

@ -559,6 +559,9 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
if (!canReenterInventoryRoot) { if (!canReenterInventoryRoot) {
return false; return false;
} }
if (intent === "inventory_aging_by_purchase_date") {
return false;
}
if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) { if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) {
return false; return false;
} }
@ -584,13 +587,13 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
} }
function hasSelectedObjectInventorySignal(text) { function hasSelectedObjectInventorySignal(text) {
const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? "")); const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? ""));
if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(repairedSelectedObjectText)) { if (/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(repairedSelectedObjectText)) {
return true; return true;
} }
if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) { if (/(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) {
return true; return true;
} }
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? "")); return /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""));
} }
function hasSelectedObjectInlineSnapshotMetadata(text) { function hasSelectedObjectInlineSnapshotMetadata(text) {
return /(?:дата\s+строки|строка\s+от|количество\s*:|стоимость\s*:|склад\s*:|организация\s*:|\|\s*(?:склад|количество|стоимость|организация|дата\s+строки)\s*:)/iu.test(String(text ?? "")); return /(?:дата\s+строки|строка\s+от|количество\s*:|стоимость\s*:|склад\s*:|организация\s*:|\|\s*(?:склад|количество|стоимость|организация|дата\s+строки)\s*:)/iu.test(String(text ?? ""));
@ -618,6 +621,7 @@ function hasInventorySupplierFollowupCue(text) {
function hasInventoryPurchaseDocumentsFollowupCue(text) { function hasInventoryPurchaseDocumentsFollowupCue(text) {
const value = textWithRepairedVariant(String(text ?? "")); const value = textWithRepairedVariant(String(text ?? ""));
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+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test(value) || 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+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test(value) ||
/(?:документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици)|какие\s+документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици))/iu.test(value) ||
/(?:по\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+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) || /(?:по\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+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) ||
/(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value)); /(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value));
} }
@ -823,6 +827,22 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("item_cleared_for_stock_slice_aging"); reasons.push("item_cleared_for_stock_slice_aging");
} }
}; };
const clearInventoryOnHandWarehouseAliasItem = () => {
if (intent !== "inventory_on_hand_as_of_date") {
return;
}
const currentItem = toNonEmptyString(merged.item);
const currentWarehouse = toNonEmptyString(merged.warehouse);
if (!currentItem || !currentWarehouse) {
return;
}
const normalizedItem = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(currentItem);
const normalizedWarehouse = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(currentWarehouse);
if (normalizedItem && normalizedWarehouse && normalizedItem === normalizedWarehouse) {
delete merged.item;
reasons.push("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot");
}
};
if (!followupContext) { if (!followupContext) {
if ((intent === "list_open_contracts" || if ((intent === "list_open_contracts" ||
intent === "open_contracts_confirmed_as_of_date" || intent === "open_contracts_confirmed_as_of_date" ||
@ -838,17 +858,20 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
: "as_of_date_derived_from_period_for_open_contracts"); : "as_of_date_derived_from_period_for_open_contracts");
} }
} }
clearInventoryOnHandWarehouseAliasItem();
clearInventoryAgingOrganizationAliasItem(null); clearInventoryAgingOrganizationAliasItem(null);
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
const previous = followupContext.previous_filters ?? {}; const previous = followupContext.previous_filters ?? {};
const root = followupContext.root_filters ?? {};
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value); const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
const previousCounterparty = toNonEmptyString(previous.counterparty); const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract); const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account); const previousAccount = toNonEmptyString(previous.account);
const previousItem = toNonEmptyString(previous.item); const previousItem = toNonEmptyString(previous.item);
const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null; const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null;
const previousOrganization = toNonEmptyString(previous.organization); const previousOrganization = toNonEmptyString(previous.organization) ?? toNonEmptyString(root.organization);
const previousWarehouse = toNonEmptyString(previous.warehouse) ?? toNonEmptyString(root.warehouse);
const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to); const previousPeriodTo = toNonEmptyString(previous.period_to);
@ -1231,6 +1254,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("period_derived_from_inventory_root_frame_year"); reasons.push("period_derived_from_inventory_root_frame_year");
} }
if (intent === "inventory_aging_by_purchase_date") { if (intent === "inventory_aging_by_purchase_date") {
if (previousWarehouse && !toNonEmptyString(merged.warehouse)) {
merged.warehouse = previousWarehouse;
reasons.push("warehouse_from_followup_context");
}
clearInventoryAgingOrganizationAliasItem(previousOrganization); clearInventoryAgingOrganizationAliasItem(previousOrganization);
} }
if (!sameDateRequested && if (!sameDateRequested &&
@ -1254,6 +1281,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
delete merged.period_to; delete merged.period_to;
reasons.push("period_cleared_by_all_time_followup"); reasons.push("period_cleared_by_all_time_followup");
} }
clearInventoryOnHandWarehouseAliasItem();
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
if (intent === "counterparty_activity_lifecycle" && if (intent === "counterparty_activity_lifecycle" &&
@ -1414,6 +1442,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("counterparty_cleared_as_organization_scope_alias"); reasons.push("counterparty_cleared_as_organization_scope_alias");
} }
} }
clearInventoryOnHandWarehouseAliasItem();
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
function resolveMissingRequiredFilters(intent, filters) { function resolveMissingRequiredFilters(intent, filters) {
@ -1441,16 +1470,50 @@ function resolveMissingRequiredFilters(intent, filters) {
}); });
} }
function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) { function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupContext) {
const normalizedMessage = String(userMessage ?? "");
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) { if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
if (hasSelectedObjectReference && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date") {
return {
intent: "inventory_purchase_documents_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_documents"]
};
}
}
if (hasSelectedObjectReference && hasInventorySupplierFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date") {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_supplier"]
};
}
}
if (hasSelectedObjectReference && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" || detectedIntent.intent === "inventory_on_hand_as_of_date") {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_purchase_date"]
};
}
}
return detectedIntent; return detectedIntent;
} }
const normalizedMessage = String(userMessage ?? "");
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage); const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
const previousFilters = followupContext.previous_filters ?? {}; const previousFilters = followupContext.previous_filters ?? {};
const purchaseBridgeContinuationSignal = followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" && const purchaseBridgeContinuationSignal = followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) && Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage); hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal; const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal || hasSelectedObjectReference;
if (!hasFollowupSignal) { if (!hasFollowupSignal) {
return detectedIntent; return detectedIntent;
} }
@ -1481,7 +1544,6 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
followupContext.root_anchor_type === "item" || followupContext.root_anchor_type === "item" ||
followupContext.current_frame_kind === "inventory_root" || followupContext.current_frame_kind === "inventory_root" ||
followupContext.current_frame_kind === "inventory_drilldown"; followupContext.current_frame_kind === "inventory_drilldown";
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal)); const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal));
const previousCounterpartyLaneActive = hasPreviousCounterparty && const previousCounterpartyLaneActive = hasPreviousCounterparty &&
(followupContext.previous_anchor_type === "counterparty" || (followupContext.previous_anchor_type === "counterparty" ||

View File

@ -57,6 +57,53 @@ function inventoryRequestedPartyMatches(requested, actualParties) {
function inventoryPartyListOrUnknown(parties) { function inventoryPartyListOrUnknown(parties) {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
} }
function inventoryPartyLeadList(parties, limit = 6) {
const visible = parties.slice(0, limit);
const remaining = parties.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function inventoryItemLeadList(items, limit = 6) {
const visible = items.slice(0, limit);
const remaining = items.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function collectInventoryTraceItems(rows) {
const result = [];
const seen = new Set();
for (const row of rows) {
const item = String(row.item ?? "").trim();
if (!item) {
continue;
}
const comparable = normalizeInventoryReplyEntityToken(item);
if (!comparable || seen.has(comparable)) {
continue;
}
seen.add(comparable);
result.push(item);
}
return result;
}
function extractInventoryCounterpartiesFromEvidenceLines(lines) {
const counterparties = [];
for (const line of lines) {
const match = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|]+)/iu.exec(line);
const value = String(match?.[1] ?? "").replace(/\s+/gu, " ").trim();
if (value) {
counterparties.push(value);
}
}
return counterparties;
}
function hasSupplierScopedItemListQuestion(options) {
if (!String(options.counterpartyHint ?? "").trim()) {
return false;
}
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
return /(?:какие?\s+товар|товар(?:ы|ов)?\s+(?:от|у)\s+поставщик|позици(?:и|я|ю)|номенклатур|лежат|остат(?:ок|ки|ка|ков))/iu.test(text);
}
function omitAmountFromInventoryTraceEvidence(lines) { function omitAmountFromInventoryTraceEvidence(lines) {
return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, "")); return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, ""));
} }
@ -131,6 +178,36 @@ function sumInventoryRowAmount(rows) {
function sumInventoryRowQuantity(rows) { function sumInventoryRowQuantity(rows) {
return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0); return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0);
} }
function cleanInventoryScopeHint(value) {
const cleaned = String(value ?? "").replace(/\s+/gu, " ").trim();
return cleaned.length > 0 ? cleaned : null;
}
function inventoryOnHandAccountScopeLabel(options) {
const accountHint = cleanInventoryScopeHint(options.accountHint);
if (accountHint) {
return `по счету ${accountHint}`;
}
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
return /(?:^|[^\d])41(?:[.,]\d+)?(?:[^\d]|$)|41\s*сч[её]т/iu.test(text) ? "по счету 41" : null;
}
function inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses) {
const warehouseHint = cleanInventoryScopeHint(options.warehouseHint);
if (warehouseHint) {
return `по складу ${warehouseHint}`;
}
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
if (uniqueWarehouses.length === 1 && /(?:по|на)\s+склад[уе]?/iu.test(text)) {
return `по складу ${uniqueWarehouses[0]}`;
}
return null;
}
function inventoryOnHandScopePhrase(options, uniqueWarehouses) {
const scopes = [
inventoryOnHandAccountScopeLabel(options),
inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses)
].filter((item) => Boolean(item));
return scopes.length > 0 ? scopes.join(" и ") : "на складе";
}
function formatInventoryPercent(value, formatNumberWithDots) { function formatInventoryPercent(value, formatNumberWithDots) {
return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`; return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`;
} }
@ -222,9 +299,10 @@ function composeInventoryReply(intent, rows, options, deps) {
const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item)); const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item));
const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)); const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const scopePhrase = inventoryOnHandScopePhrase(options, uniqueWarehouses);
const directAnswerLine = positions.length > 0 const directAnswerLine = positions.length > 0
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.` ? `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; : `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтвержденных товарных остатков не найдено.`;
const lines = [directAnswerLine]; const lines = [directAnswerLine];
if (positions.length > 0) { if (positions.length > 0) {
const visiblePositionsLimit = 6; const visiblePositionsLimit = 6;
@ -250,13 +328,22 @@ function composeInventoryReply(intent, rows, options, deps) {
"- На дату среза товары с ненулевым остатком не найдены." "- На дату среза товары с ненулевым остатком не найдены."
]); ]);
} }
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ const summaryBullets = [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`, `Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, `Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, `Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции." "Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
]); ];
const accountScope = inventoryOnHandAccountScopeLabel(options);
const warehouseScope = inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses);
if (accountScope) {
summaryBullets.splice(1, 0, `Срез ${accountScope}.`);
}
if (warehouseScope) {
summaryBullets.splice(accountScope ? 2 : 1, 0, `Срез ${warehouseScope}.`);
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", summaryBullets);
if (rows.length !== positions.length) { if (rows.length !== positions.length) {
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`); lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
} }
@ -398,49 +485,111 @@ function composeInventoryReply(intent, rows, options, deps) {
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const summary = deps.summarizeInventoryTraceRows(purchaseRows); const summary = deps.summarizeInventoryTraceRows(purchaseRows);
normalizeInventoryTraceSummaryCounterparties(summary); normalizeInventoryTraceSummaryCounterparties(summary);
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); const purchaseRowCounterparties = purchaseRows.map((row) => deps.uniqueStrings([
const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(options.userMessage ?? "")); ...deps.extractInventoryCounterpartyCandidates(row),
...extractInventoryCounterpartiesFromEvidenceLines(deps.formatInventoryTraceRows([row], 1))
]));
const observedCounterparties = deps.uniqueStrings([
...summary.counterparties,
...purchaseRowCounterparties.flat()
]);
const unresolvedRows = purchaseRows.filter((_, index) => (purchaseRowCounterparties[index]?.length ?? 0) === 0);
const unresolvedSupplierQuestionText = `${String(options.userMessage ?? "")}\n${String(options.rawUserMessage ?? "")}`;
const unresolvedSupplierQuestion = /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(unresolvedSupplierQuestionText);
if (unresolvedSupplierQuestion) { if (unresolvedSupplierQuestion) {
const unresolvedItems = collectInventoryTraceItems(unresolvedRows);
const unresolvedItemPreviewLimit = 3;
const unresolvedDocumentPreviewLimit = 2;
const directAnswerLine = unresolvedRows.length > 0 const directAnswerLine = unresolvedRows.length > 0
? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` ? unresolvedItems.length > 0
? `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено ${deps.formatNumberWithDots(unresolvedItems.length)} позиций без надежной привязки к поставщику; первые примеры ниже.`
: `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`
: "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены."; : "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены.";
const lines = [directAnswerLine]; const lines = [directAnswerLine];
if (unresolvedItems.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции без надежной привязки к поставщику:", unresolvedItems.slice(0, unresolvedItemPreviewLimit).map((item, index) => `${index + 1}. ${item}`));
if (unresolvedItems.length > unresolvedItemPreviewLimit) {
lines.push(`- Показаны первые ${unresolvedItemPreviewLimit} из ${deps.formatNumberWithDots(unresolvedItems.length)} таких позиций.`);
}
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`, `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`,
`Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`, `Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`,
`Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.` `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(observedCounterparties.length)}.`
]); ]);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
"Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии." "Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии."
]); ]);
if (unresolvedRows.length > 0) { if (unresolvedRows.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции без явно выделенного поставщика:", deps.formatInventoryTraceRows(unresolvedRows, 12)); (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(unresolvedRows, unresolvedDocumentPreviewLimit));
if (unresolvedRows.length > unresolvedDocumentPreviewLimit) {
lines.push(`- Показаны первые ${unresolvedDocumentPreviewLimit} из ${deps.formatNumberWithDots(unresolvedRows.length)} строк без выделенного поставщика.`);
}
} }
else if (summary.counterparties.length > 0) { else if (observedCounterparties.length > 0) {
lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); lines.push(`- В доступном закупочном следе встречаются поставщики: ${observedCounterparties.slice(0, 6).join("; ")}.`);
} }
return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(unresolvedRows.length > 0 ? "medium" : "strong", true)); return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(unresolvedRows.length > 0 ? "medium" : "strong", true));
} }
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
const directAnswerLine = summary.counterparties.length === 1 const purchasePeriodLabel = summary.firstPeriod && summary.lastPeriod
? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` ? `${deps.inventoryTraceDateLabel(summary.firstPeriod)}..${deps.inventoryTraceDateLabel(summary.lastPeriod)}`
: summary.counterparties.length > 1 : "даты закупки не выделены";
? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` const supplierScopedItems = collectInventoryTraceItems(purchaseRows);
: `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`; const supplierScopedItemQuestion = hasSupplierScopedItemListQuestion(options);
if (supplierScopedItemQuestion) {
const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim();
const directAnswerLine = supplierScopedItems.length > 0
? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.`
: `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`;
const lines = [directAnswerLine];
if (supplierScopedItems.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", supplierScopedItems.slice(0, 8).map((item, index) => `${index + 1}. ${item}`));
if (supplierScopedItems.length > 8) {
lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`);
}
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Период найденного закупочного следа: ${purchasePeriodLabel}.`,
`Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
]);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
]);
if (observedCounterparties.length > 0) {
lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
}
if (purchaseRows.length > 0) {
(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 ? (supplierScopedItems.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0));
}
const directAnswerLine = observedCounterparties.length === 1
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} выявлен поставщик закупочного следа: ${observedCounterparties[0]}.`
: observedCounterparties.length > 1
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} найдено несколько поставщиков закупочного следа: ${inventoryPartyLeadList(observedCounterparties)}.`
: unresolvedRows.length > 0
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначный поставщик текущего остатка не подтвержден: в ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операциях поставщик не выделен.`
: `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} поставщик в доступных данных не выделен.`;
const lines = [directAnswerLine]; const lines = [directAnswerLine];
if (unresolvedRows.length > 0) {
lines.push(`По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначная атрибуция части текущего остатка не подтверждена: ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операций без явно выделенного поставщика.`);
}
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [ (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Что проверили:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, `Период найденного закупочного следа: ${purchasePeriodLabel}.`,
`Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
`Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`
]); ]);
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [ (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
]); ]);
if (summary.counterparties.length > 0) { if (observedCounterparties.length > 0) {
lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
} }
else if (purchaseRows.length > 0) { else if (purchaseRows.length > 0) {
lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных."); lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных.");
@ -448,11 +597,11 @@ function composeInventoryReply(intent, rows, options, deps) {
else { else {
lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу."); lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу.");
} }
if (unresolvedRows.length > 0) {
lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`);
}
if (purchaseRows.length > 0) { if (purchaseRows.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10)); (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 > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0));
} }
@ -770,17 +919,29 @@ function composeInventoryReply(intent, rows, options, deps) {
const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties); const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties);
const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties); const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties);
const mismatchParts = []; const mismatchParts = [];
if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { if (requestedSupplier && purchaseRows.length === 0) {
mismatchParts.push(`закупка у запрошенного поставщика ${requestedSupplier} не найдена`);
}
else if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) {
mismatchParts.push(`запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}`); mismatchParts.push(`запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}`);
} }
if (requestedBuyer && saleRows.length > 0 && !buyerMatches) { if (requestedBuyer && saleRows.length === 0) {
mismatchParts.push(`продажа/выбытие запрошенному покупателю ${requestedBuyer} не найдены`);
}
else if (requestedBuyer && saleRows.length > 0 && !buyerMatches) {
mismatchParts.push(`запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}`); mismatchParts.push(`запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}`);
} }
const directAnswerLine = mismatchParts.length > 0 const directAnswerLine = mismatchParts.length > 0
? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.` ? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.`
: purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 : purchaseRows.length > 0 && saleRows.length > 0 && purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; : purchaseRows.length > 0 && saleRows.length > 0
? `По товару ${itemLabel} найдены обе стороны цепочки поставки и продажи; участников несколько или они неоднозначны, детали ниже.`
: purchaseRows.length > 0
? `По товару ${itemLabel} закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.`
: saleRows.length > 0
? `По товару ${itemLabel} найдена продажа/выбытие со счета 41.01, но закупочная часть цепочки в доступных данных не подтверждена.`
: `По товару ${itemLabel} цепочка поставки и продажи не подтверждена: движений по счету 41.01 в доступных данных не найдено.`;
const lines = [directAnswerLine, "", "Подтверждение:"]; const lines = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`);

View File

@ -133,11 +133,17 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
semanticFrame.anchor_value = scopeTargetText; semanticFrame.anchor_value = scopeTargetText;
} }
if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) { if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) {
extractedFilters.counterparty = scopeTargetText; if ((0, addressFilterExtractor_1.isLowQualityCounterpartyAnchorValue)(scopeTargetText)) {
pushWarning(warnings, "counterparty_from_llm_semantics"); delete extractedFilters.counterparty;
semanticFrame.scope_kind = "explicit_anchor"; pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
semanticFrame.anchor_kind = "counterparty"; }
semanticFrame.anchor_value = scopeTargetText; else {
extractedFilters.counterparty = scopeTargetText;
pushWarning(warnings, "counterparty_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "counterparty";
semanticFrame.anchor_value = scopeTargetText;
}
} }
if (semanticHints.scope_target_kind === "contract" && scopeTargetText) { if (semanticHints.scope_target_kind === "contract" && scopeTargetText) {
extractedFilters.contract = scopeTargetText; extractedFilters.contract = scopeTargetText;
@ -159,6 +165,11 @@ function applyAddressLlmSemanticHintsToExtraction(extraction, semanticHintsInput
pushWarning(warnings, "item_llm_semantics_ignored"); pushWarning(warnings, "item_llm_semantics_ignored");
} }
} }
if (toNonEmptyString(extractedFilters.counterparty) &&
(0, addressFilterExtractor_1.isLowQualityCounterpartyAnchorValue)(String(extractedFilters.counterparty))) {
delete extractedFilters.counterparty;
pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
}
return { return {
...extraction, ...extraction,
extracted_filters: extractedFilters, extracted_filters: extractedFilters,

View File

@ -31,6 +31,182 @@ function toNullableBoolean(value) {
function normalizeAddressReplyType(value) { function normalizeAddressReplyType(value) {
return value === "factual" || value === "partial_coverage" ? value : "partial_coverage"; return value === "factual" || value === "partial_coverage" ? value : "partial_coverage";
} }
function uniqueNormalizedStrings(values) {
const result = [];
const seen = new Set();
for (const value of values) {
const text = String(value ?? "").trim();
if (!text) {
continue;
}
const key = text.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(text);
}
return result;
}
function extractInventorySupplierOverlapCounterpartiesFromReply(replyText) {
const values = [];
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\r\n]+)/giu;
for (const match of replyText.matchAll(pattern)) {
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
continue;
}
values.push(value);
}
return uniqueNormalizedStrings(values);
}
function formatInventorySupplierOverlapCounterpartyLead(counterparties, limit = 6) {
const visible = counterparties.slice(0, limit);
const remaining = counterparties.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function hasInventorySupplierOverlapDefensiveLead(firstLine) {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized);
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized);
return startsWithStockScope && hasDefensiveAttribution;
}
function firstNonEmptyReplyLine(text) {
return String(text ?? "")
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? "";
}
function hasInventorySupplierOverlapCounterpartyLead(firstLine) {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
return (/^\u043f\u043e\s+\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443/u.test(normalized) &&
/\u043d\u0430\u0439\u0434\u0435\u043d/u.test(normalized) &&
/\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a/u.test(normalized) &&
/\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u043e\u0433\u043e\s+\u0441\u043b\u0435\u0434\u0430/u.test(normalized));
}
function preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize(input) {
const debug = toRecordObject(input.addressDebug);
const detectedIntent = toNullableString(debug?.detected_intent);
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
return { text: input.sanitizedReply, applied: false };
}
const rawLead = firstNonEmptyReplyLine(input.rawReply);
if (!hasInventorySupplierOverlapCounterpartyLead(rawLead)) {
return { text: input.sanitizedReply, applied: false };
}
const sanitizedLead = firstNonEmptyReplyLine(input.sanitizedReply);
if (hasInventorySupplierOverlapCounterpartyLead(sanitizedLead)) {
return { text: input.sanitizedReply, applied: false };
}
const sanitizedText = String(input.sanitizedReply ?? "").trim();
return {
text: sanitizedText ? [rawLead, sanitizedText].join("\n\n") : rawLead,
applied: true
};
}
function inspectInventorySupplierOverlapDefensiveLead(firstLine) {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
return {
stockScopeMatch: /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(normalized),
defensiveMatch: /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(normalized)
};
}
function buildInventorySupplierOverlapSurfaceRepairAudit(input) {
return {
schema_version: "inventory_supplier_overlap_surface_repair_v1",
applied: input.applied,
detected_intent: input.detectedIntent,
counterparties: input.counterparties ?? [],
first_line_sample: input.firstLineSample ?? null,
stock_scope_match: input.stockScopeMatch ?? null,
defensive_match: input.defensiveMatch ?? null,
reason_codes: input.reasonCodes
};
}
function repairInventorySupplierOverlapSurfaceReply(replyText, addressDebug) {
const text = String(replyText ?? "");
const debug = toRecordObject(addressDebug);
const detectedIntent = toNullableString(debug?.detected_intent);
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
return {
text,
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: false,
detectedIntent,
reasonCodes: ["inventory_supplier_overlap_surface_repair_intent_not_applicable"]
})
};
}
const lines = text.split(/\r?\n/u);
const firstLine = lines[0]?.trim() ?? "";
const leadInspection = inspectInventorySupplierOverlapDefensiveLead(firstLine);
if (!hasInventorySupplierOverlapDefensiveLead(firstLine)) {
return {
text,
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: false,
detectedIntent,
firstLineSample: firstLine.slice(0, 220),
stockScopeMatch: leadInspection.stockScopeMatch,
defensiveMatch: leadInspection.defensiveMatch,
reasonCodes: ["inventory_supplier_overlap_surface_repair_first_line_not_defensive"]
})
};
}
const counterparties = extractInventorySupplierOverlapCounterpartiesFromReply(text);
if (counterparties.length === 0) {
return {
text,
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: false,
detectedIntent,
counterparties,
firstLineSample: firstLine.slice(0, 220),
stockScopeMatch: leadInspection.stockScopeMatch,
defensiveMatch: leadInspection.defensiveMatch,
reasonCodes: ["inventory_supplier_overlap_surface_repair_no_counterparties"]
})
};
}
const repairedLead = counterparties.length === 1
? `По складскому остатку найден поставщик закупочного следа: ${counterparties[0]}.`
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatInventorySupplierOverlapCounterpartyLead(counterparties)}.`;
return {
text: [repairedLead, firstLine, ...lines.slice(1)].join("\n"),
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: true,
detectedIntent,
counterparties,
firstLineSample: firstLine.slice(0, 220),
stockScopeMatch: leadInspection.stockScopeMatch,
defensiveMatch: leadInspection.defensiveMatch,
reasonCodes: ["inventory_supplier_overlap_surface_repair_applied"]
})
};
}
function extractSingleWarehouseScopeFromReply(replyText) {
const values = [];
const normalized = String(replyText ?? "");
const byFieldPattern = /(?:^|\|)\s*склад\s*:\s*([^|\r\n]+)/giu;
for (const match of normalized.matchAll(byFieldPattern)) {
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
if (value) {
values.push(value);
}
}
const fieldUnique = uniqueNormalizedStrings(values);
if (fieldUnique.length > 0) {
return fieldUnique.length === 1 ? fieldUnique[0] : null;
}
const leadMatch = normalized.match(/(?:по|на)\s+склад[уе]?\s+([^.,;\r\n]+)/iu);
const leadWarehouse = toNullableString(leadMatch?.[1]?.replace(/[.;,\s]+$/u, ""));
if (leadWarehouse) {
values.push(leadWarehouse);
}
const unique = uniqueNormalizedStrings(values);
return unique.length === 1 ? unique[0] : null;
}
function sameBusinessLabel(left, right) { function sameBusinessLabel(left, right) {
const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
@ -65,7 +241,7 @@ function cleanComparisonScopeCompanyLine(line, organization) {
.replace(/\bcompany-level\b/giu, "общий по компании") .replace(/\bcompany-level\b/giu, "общий по компании")
.replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез"); .replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез");
if (organization) { if (organization) {
clean = clean.replace(/по компании\s+Альтернатива Плюс/iu, `по компании ${organization}`); clean = clean.replace(/по компании\s+.+?(?=\s+(?:подтвержден|подтвержд[её]н|зафиксирован|показан|есть)\b|:|$)/iu, `по компании ${organization}`);
} }
return clean.trim(); return clean.trim();
} }
@ -77,9 +253,7 @@ function hasComparisonScopeProofCue(value) {
return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(text); return /(?:\bсравни\b|собери\s+коротк\p{L}*\s+итог|коротк\p{L}*\s+сравн|что\s+.*подтверд\p{L}*.*(?:компан|контрагент|свк)|что\s+.*отдельно\s+по\s+(?:группа\s+свк|выбранн\p{L}*\s+контрагент)|какие\s+выводы\s+можно\s+делать\s+и\s+какие\s+нельзя)/iu.test(text);
} }
function buildComparisonScopeProofReply(input) { function buildComparisonScopeProofReply(input) {
if (!hasComparisonScopeProofCue(input.userMessage)) { const legalOrganizationFromCurrentTurn = legalOrganizationLabelFromClarification(input.userMessage);
return null;
}
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1); const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input); const turnInput = toRecordObject(entryPoint?.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
@ -87,6 +261,9 @@ function buildComparisonScopeProofReply(input) {
if (!isBusinessOverview) { if (!isBusinessOverview) {
return null; return null;
} }
if (!hasComparisonScopeProofCue(input.userMessage) && !legalOrganizationFromCurrentTurn) {
return null;
}
const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates) const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates)
? turnMeaning.business_overview_separate_entity_candidates ? turnMeaning.business_overview_separate_entity_candidates
: []; : [];
@ -113,7 +290,7 @@ function buildComparisonScopeProofReply(input) {
if (!incomingAmount && !outgoingAmount && !netAmount) { if (!incomingAmount && !outgoingAmount && !netAmount) {
return null; return null;
} }
const organization = legalOrganizationLabelFromClarification(input.userMessage) const organization = legalOrganizationFromCurrentTurn
?? toNullableString(turnMeaning?.explicit_organization_scope) ?? toNullableString(turnMeaning?.explicit_organization_scope)
?? toNullableString(toRecordObject(comparisonScope?.organization)?.label); ?? toNullableString(toRecordObject(comparisonScope?.organization)?.label);
const documentCount = Number(toRecordObject(documentBundle)?.document_count); const documentCount = Number(toRecordObject(documentBundle)?.document_count);
@ -277,8 +454,21 @@ function normalizeLlmPreDecomposeMeta(value) {
} }
function runAssistantAddressLaneResponseRuntime(input) { function runAssistantAddressLaneResponseRuntime(input) {
const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? assistantAddressTurnFinalizeRuntimeAdapter_1.finalizeAssistantAddressTurn; const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? assistantAddressTurnFinalizeRuntimeAdapter_1.finalizeAssistantAddressTurn;
const safeAddressReply = input.sanitizeOutgoingAssistantText(input.addressLane.reply_text);
const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta); const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta);
const surfaceRepair = repairInventorySupplierOverlapSurfaceReply(input.addressLane.reply_text, debug);
const safeAddressReplyBase = input.sanitizeOutgoingAssistantText(surfaceRepair.text);
const preservedAddressReply = preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize({
rawReply: surfaceRepair.text,
sanitizedReply: safeAddressReplyBase,
addressDebug: debug
});
const safeAddressReply = preservedAddressReply.text;
if (surfaceRepair.audit) {
debug.inventory_supplier_overlap_surface_repair_v1 = {
...surfaceRepair.audit,
lead_preserved_after_sanitize: preservedAddressReply.applied
};
}
const followupOffer = input.buildAddressFollowupOffer(debug); const followupOffer = input.buildAddressFollowupOffer(debug);
if (followupOffer) { if (followupOffer) {
debug.address_followup_offer = followupOffer; debug.address_followup_offer = followupOffer;
@ -309,14 +499,36 @@ function runAssistantAddressLaneResponseRuntime(input) {
const rootFilters = followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object" const rootFilters = followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object"
? followupContextSource.root_filters ? followupContextSource.root_filters
: null; : null;
if (rootIntent || currentFrameKind) { const detectedIntent = input.toNonEmptyString(debug.detected_intent);
const rootFrameWarehouse = input.toNonEmptyString(rootFilters?.warehouse) ??
input.toNonEmptyString(debugFilters?.warehouse) ??
(detectedIntent === "inventory_on_hand_as_of_date" ? extractSingleWarehouseScopeFromReply(safeAddressReply) : null);
const shouldPersistInventoryRootFrame = rootIntent || currentFrameKind || detectedIntent === "inventory_on_hand_as_of_date";
if (shouldPersistInventoryRootFrame) {
const rootFiltersForDebug = {
...(rootFilters ?? {}),
organization: input.toNonEmptyString(rootFilters?.organization) ?? input.toNonEmptyString(debugFilters?.organization),
warehouse: rootFrameWarehouse,
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date) ?? input.toNonEmptyString(debugFilters?.as_of_date),
period_from: input.toNonEmptyString(rootFilters?.period_from) ?? input.toNonEmptyString(debugFilters?.period_from),
period_to: input.toNonEmptyString(rootFilters?.period_to) ?? input.toNonEmptyString(debugFilters?.period_to)
};
for (const key of Object.keys(rootFiltersForDebug)) {
if (!input.toNonEmptyString(rootFiltersForDebug[key])) {
delete rootFiltersForDebug[key];
}
}
debug.address_root_frame_context = { debug.address_root_frame_context = {
root_intent: rootIntent, root_intent: rootIntent ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_on_hand_as_of_date" : null),
current_frame_kind: currentFrameKind, root_filters: rootFiltersForDebug,
organization: input.toNonEmptyString(rootFilters?.organization), root_anchor_type: input.toNonEmptyString(followupContextSource?.root_anchor_type) ?? undefined,
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date), root_anchor_value: input.toNonEmptyString(followupContextSource?.root_anchor_value) ?? undefined,
period_from: input.toNonEmptyString(rootFilters?.period_from), current_frame_kind: currentFrameKind ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_root" : null),
period_to: input.toNonEmptyString(rootFilters?.period_to) organization: input.toNonEmptyString(rootFiltersForDebug.organization),
warehouse: input.toNonEmptyString(rootFiltersForDebug.warehouse),
as_of_date: input.toNonEmptyString(rootFiltersForDebug.as_of_date),
period_from: input.toNonEmptyString(rootFiltersForDebug.period_from),
period_to: input.toNonEmptyString(rootFiltersForDebug.period_to)
}; };
} }
const debugWithRuntimeContracts = (0, assistantRuntimeContractResolver_1.attachAssistantRuntimeContractShadow)(debug, { const debugWithRuntimeContracts = (0, assistantRuntimeContractResolver_1.attachAssistantRuntimeContractShadow)(debug, {

View File

@ -948,9 +948,21 @@ function buildInventoryRootFrameFromAddressDebug(debug, toNonEmptyString = fallb
return null; return null;
} }
const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters); const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters);
const rootFrameFiltersCandidate = rootFrameContext
? {
organization: toNonEmptyString(rootFrameContext.organization),
warehouse: toNonEmptyString(rootFrameContext.warehouse),
as_of_date: toNonEmptyString(rootFrameContext.as_of_date),
period_from: toNonEmptyString(rootFrameContext.period_from),
period_to: toNonEmptyString(rootFrameContext.period_to)
}
: null;
const rootFrameFilters = rootFrameFiltersCandidate
? Object.fromEntries(Object.entries(rootFrameFiltersCandidate).filter(([, value]) => Boolean(value)))
: null;
const filters = { const filters = {
...(rootFiltersCandidate ?? {}), ...(rootFiltersCandidate ?? {}),
...(rootFiltersCandidate ? {} : extractedFilters) ...(rootFiltersCandidate ? {} : rootFrameFilters && Object.keys(rootFrameFilters).length > 0 ? rootFrameFilters : extractedFilters)
}; };
if (!filters.organization) { if (!filters.organization) {
const organization = readAddressDebugOrganization(debug, toNonEmptyString); const organization = readAddressDebugOrganization(debug, toNonEmptyString);

View File

@ -405,6 +405,30 @@ function hasInventoryMarginRankingAddressReply(input, entryPoint) {
selectedRecipe === "address_inventory_margin_ranking_for_nomenclature_v1" || selectedRecipe === "address_inventory_margin_ranking_for_nomenclature_v1" ||
capabilityId === "inventory_inventory_margin_ranking_for_nomenclature"); capabilityId === "inventory_inventory_margin_ranking_for_nomenclature");
} }
function hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
if (!hasEffectivelyFactualAddressReply(input)) {
return false;
}
const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase();
if (source !== "address_query_runtime_v1" && source !== "address_exact" && source !== "address_lane") {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const capabilityId = toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id);
if (detectedIntent !== "inventory_purchase_to_sale_chain" &&
selectedRecipe !== "address_inventory_purchase_to_sale_chain_v1" &&
capabilityId !== "inventory_inventory_purchase_to_sale_chain") {
return false;
}
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
const responseType = toNonEmptyString(input.addressRuntimeMeta?.response_type);
return Boolean(hasConfirmedAddressExecution(input) || mcpCallStatus === "matched_non_empty" || responseType === "FACTUAL_LIST");
}
function hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint) { function hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false; return false;
@ -471,6 +495,14 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) {
} }
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
const askedAction = toNonEmptyString(turnMeaning?.asked_action_family);
if (detectedIntent === "inventory_supplier_stock_overlap_as_of_date" &&
hasConfirmedAddressExecution(input) &&
(askedDomain === "entity_resolution" || askedAction === "search_business_entity")) {
return true;
}
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) { if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
return false; return false;
} }
@ -682,6 +714,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint); const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint); const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint);
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint); const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint);
@ -689,6 +722,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply &&
!semanticConflictWithDiscoveryTurnMeaning && !semanticConflictWithDiscoveryTurnMeaning &&
!valueFlowActionConflictWithDiscoveryTurnMeaning; !valueFlowActionConflictWithDiscoveryTurnMeaning;
const exactInventoryChainProtectsCurrent = exactInventoryPurchaseToSaleChainAddressReply &&
candidate.candidate_status === "clarification_candidate" &&
valueFlowActionConflictWithDiscoveryTurnMeaning;
if (!entryPoint) { if (!entryPoint) {
pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point");
} }
@ -755,6 +791,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
if (inventoryMarginRankingAddressReply) { if (inventoryMarginRankingAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply"); pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply");
} }
if (exactInventoryChainProtectsCurrent) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification");
}
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate"); pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate");
} }
@ -787,6 +826,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
!exactBankOperationsProtectsCurrent && !exactBankOperationsProtectsCurrent &&
!exactDocumentListAddressReply && !exactDocumentListAddressReply &&
!inventoryMarginRankingAddressReply && !inventoryMarginRankingAddressReply &&
!exactInventoryChainProtectsCurrent &&
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
candidate.eligible_for_future_hot_runtime && candidate.eligible_for_future_hot_runtime &&

View File

@ -67,6 +67,13 @@ function detectSupportedIntent(text, deps) {
reason: "address_intent_resolver_current_turn_signal" reason: "address_intent_resolver_current_turn_signal"
}; };
} }
if (hasInventorySupplierStockOverlapExactSignal(text)) {
return {
intent: "inventory_supplier_stock_overlap_as_of_date",
confidence: "high",
reason: "inventory_supplier_stock_overlap_current_turn_signal"
};
}
if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) { if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) {
return { return {
intent: "receivables_confirmed_as_of_date", intent: "receivables_confirmed_as_of_date",
@ -214,6 +221,17 @@ function hasSelectedObjectInventoryExactSignal(text) {
} }
return /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u0440\u043e\u0434\u0430\u043b|\u0437\u0430\u043a\u0443\u043f|\u043f\u043e\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0446\u0435\u043f\u043e\u0447|profit|margin|sale|purchase|document)/iu.test(normalized); return /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u0440\u043e\u0434\u0430\u043b|\u0437\u0430\u043a\u0443\u043f|\u043f\u043e\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0446\u0435\u043f\u043e\u0447|profit|margin|sale|purchase|document)/iu.test(normalized);
} }
function hasInventorySupplierStockOverlapExactSignal(text) {
const normalized = String(text ?? "");
if (!normalized) {
return false;
}
const hasStockScope = /(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442|\u043b\u0435\u0436\p{L}*|\u043d\u0430\u0445\u043e\u0434\p{L}*|\u0447\u0438\u0441\u043b\p{L}*|warehouse|stock|inventory|on\s+hand)/iu.test(normalized);
const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0437\u0430\u043a\u0443\u043f|\u043a\u0443\u043f\p{L}*|\u043f\u0440\u0438\u043e\u0431\u0440\p{L}*|supplier|vendor|purchase|bought|supplied|procurement)/iu.test(normalized);
const hasOverlapAction = /(?:\u0443\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\s+\u043a\u0430\u043a\u043e\u043c\u0443\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*[\s\S]{0,80}\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0439\u0442\p{L}*[\s\S]{0,80}\u0442\u043e\u0432\u0430\u0440|\u043a\u0430\u043a\u0438\u0435\s+\u0442\u043e\u0432\u0430\u0440|supplier[\s\S]{0,80}(?:stock|inventory)|(?:stock|inventory)[\s\S]{0,80}supplier)/iu.test(normalized);
const hasSupplierIdentityAsk = /(?:\u043a\u0430\u043a\u043e(?:\u0433\u043e|\u043c\u0443)\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|which\s+supplier|what\s+supplier)/iu.test(normalized);
return hasStockScope && hasSupplierCue && (hasOverlapAction || hasSupplierIdentityAsk);
}
function hasOrganizationLevelEarningsOverviewSignal(text) { function hasOrganizationLevelEarningsOverviewSignal(text) {
const normalized = String(text ?? ""); const normalized = String(text ?? "");
if (!normalized || hasExplicitCounterpartyValueObject(normalized)) { if (!normalized || hasExplicitCounterpartyValueObject(normalized)) {
@ -381,12 +399,15 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText); const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText);
const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText); const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText);
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText); const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
const inventorySupplierStockOverlapExact = supportedIntent?.intent === "inventory_supplier_stock_overlap_as_of_date" ||
llmIntent === "inventory_supplier_stock_overlap_as_of_date" ||
hasInventorySupplierStockOverlapExactSignal(joinedText);
const broadBusinessEvaluation = compactOrganizationCashflowDisplay const broadBusinessEvaluation = compactOrganizationCashflowDisplay
? { family: "broad_business_evaluation" } ? { family: "broad_business_evaluation" }
: selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family : selectedObjectInventoryExact || inventorySupplierStockOverlapExact || counterpartyBidirectionalValueFlow?.family
? null ? null
: detectBroadBusinessEvaluation(joinedText); : detectBroadBusinessEvaluation(joinedText);
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
const explicitIntentCandidate = broadBusinessEvaluation?.family const explicitIntentCandidate = broadBusinessEvaluation?.family
? null ? null
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); : supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
@ -454,11 +475,13 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
? "confirmed_snapshot" ? "confirmed_snapshot"
: explicitIntentCandidate === "vat_payable_forecast" : explicitIntentCandidate === "vat_payable_forecast"
? "forecast" ? "forecast"
: explicitIntentCandidate === "list_documents_by_counterparty" : explicitIntentCandidate === "inventory_supplier_stock_overlap_as_of_date"
? "list_documents" ? "supplier_overlap"
: counterpartyTurnover?.family : explicitIntentCandidate === "list_documents_by_counterparty"
? "counterparty_value_or_turnover" ? "list_documents"
: null; : counterpartyTurnover?.family
? "counterparty_value_or_turnover"
: null;
const staleReplayForbidden = Boolean(unsupportedFamily || const staleReplayForbidden = Boolean(unsupportedFamily ||
broadBusinessEvaluation?.family || broadBusinessEvaluation?.family ||
(counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) || (counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) ||

View File

@ -726,17 +726,21 @@ function extractKnownFinancialCounterpartyAnchor(text: string): string | undefin
return undefined; return undefined;
} }
function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { export function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
const value = String(rawValue ?? "") const value = String(rawValue ?? "")
.trim() .trim()
.toLowerCase() .toLowerCase()
.replace(/ё/g, "е"); .replace(/ё/g, "е")
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
if (!value) { if (!value) {
return true; return true;
} }
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) { if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
return true; return true;
} }
if (/^(?:товар(?:ы|а|ов|у|ом|ами|ах)?|номенклатур(?:а|ы|у|ой|ами)?|позици(?:я|и|ю|ей)|остат(?:ок|ки|ка|ков|кам|ками)?|склад|складе|складу)$/iu.test(value)) {
return true;
}
if ( if (
/^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test( /^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test(
value value

View File

@ -2465,6 +2465,16 @@ function asksForUnresolvedInventorySupplierLink(userMessage: string | null | und
); );
} }
function messagePairAsksForUnresolvedInventorySupplierLink(
userMessage: string | null | undefined,
rawUserMessage: string | null | undefined
): boolean {
return (
asksForUnresolvedInventorySupplierLink(userMessage) ||
asksForUnresolvedInventorySupplierLink(rawUserMessage)
);
}
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean { function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
if (Array.isArray((filters as { warnings?: unknown }).warnings) && (filters as { warnings?: string[] }).warnings?.includes("exact_historical_period_window_requested")) { if (Array.isArray((filters as { warnings?: unknown }).warnings) && (filters as { warnings?: string[] }).warnings?.includes("exact_historical_period_window_requested")) {
return false; return false;
@ -2530,7 +2540,12 @@ function shouldDetachLifecycleExecutionFromSnapshotContext(
return false; return false;
} }
const historyIntent =
intent === "inventory_sale_trace_for_item" ||
intent === "inventory_profitability_for_item" ||
intent === "inventory_purchase_to_sale_chain";
return ( return (
(historyIntent && reasons.includes("as_of_date_from_analysis_context")) ||
reasons.includes("period_window_semantic_from_inventory_snapshot_context") || reasons.includes("period_window_semantic_from_inventory_snapshot_context") ||
reasons.includes("period_window_semantic_from_inventory_as_of_month") || reasons.includes("period_window_semantic_from_inventory_as_of_month") ||
reasons.includes("as_of_date_from_followup_context") || reasons.includes("as_of_date_from_followup_context") ||
@ -2629,6 +2644,90 @@ function injectNoticeAfterLeadLine(text: string, notice: string): string {
return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n"); return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n");
} }
function injectNoticeBeforeLeadLine(text: string, notice: string): string {
const normalizedText = typeof text === "string" ? text : "";
const normalizedNotice = typeof notice === "string" ? notice.trim() : "";
if (!normalizedText.trim()) {
return normalizedNotice;
}
if (!normalizedNotice) {
return normalizedText;
}
return [normalizedNotice, normalizedText].join("\n");
}
function injectAutoBroadenedPeriodNotice(intent: string, text: string, notice: string): string {
if (intent === "inventory_supplier_stock_overlap_as_of_date") {
return injectNoticeBeforeLeadLine(text, notice);
}
return injectNoticeAfterLeadLine(text, notice);
}
function extractSupplierOverlapReplyCounterparties(text: string): string[] {
const counterparties: string[] = [];
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\n]+)/giu;
for (const match of String(text ?? "").matchAll(pattern)) {
const value = String(match[1] ?? "").replace(/\s+/gu, " ").trim();
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
continue;
}
counterparties.push(value);
}
return uniqueStrings(counterparties);
}
function formatSupplierOverlapCounterpartyLead(counterparties: string[], limit = 6): string {
const visible = counterparties.slice(0, limit);
const remaining = counterparties.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function hasInventorySupplierOverlapDefensiveLead(firstLine: string): boolean {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(
normalized
);
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(
normalized
);
return startsWithStockScope && hasDefensiveAttribution;
}
export function ensureInventorySupplierOverlapLeadLine(intent: string, text: string): string {
if (intent !== "inventory_supplier_stock_overlap_as_of_date") {
return text;
}
const normalizedText = typeof text === "string" ? text : "";
const lines = normalizedText.split(/\r?\n/u);
const firstLine = lines[0]?.trim() ?? "";
const displayedCounterparties = extractSupplierOverlapReplyCounterparties(normalizedText);
if (
displayedCounterparties.length > 0 &&
hasInventorySupplierOverlapDefensiveLead(firstLine)
) {
const repairedLead =
displayedCounterparties.length === 1
? `По складскому остатку найден поставщик закупочного следа: ${displayedCounterparties[0]}.`
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatSupplierOverlapCounterpartyLead(displayedCounterparties)}.`;
return [repairedLead, firstLine, ...lines.slice(1)].join("\n");
}
if (
!normalizedText.trim() ||
/^По\s+(?:складскому\s+остатку|окну)\b/iu.test(firstLine) ||
/^В\s+текущем\s+складском\s+срезе\b/iu.test(firstLine)
) {
return normalizedText;
}
if (!/^(?:Что\s+проверили:|Часть\s+закупочных\s+операций|Опорные\s+документы:)/iu.test(firstLine)) {
return normalizedText;
}
return [
"По складскому остатку однозначный поставщик текущего остатка не подтвержден; ниже показан проверенный закупочный след и ограничения.",
normalizedText
].join("\n");
}
function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness { function runtimeReadinessForLimitedCategory(category: AddressLimitedReasonCategory): AddressRuntimeReadiness {
if (category === "empty_match" || category === "missing_anchor") { if (category === "empty_match" || category === "missing_anchor") {
return "LIVE_QUERYABLE_WITH_LIMITS"; return "LIVE_QUERYABLE_WITH_LIMITS";
@ -3684,7 +3783,7 @@ export class AddressQueryService {
intent.intent === "inventory_supplier_stock_overlap_as_of_date" && intent.intent === "inventory_supplier_stock_overlap_as_of_date" &&
!toNonEmptyFilterValue(filters.extracted_filters.period_from) && !toNonEmptyFilterValue(filters.extracted_filters.period_from) &&
!toNonEmptyFilterValue(filters.extracted_filters.period_to) && !toNonEmptyFilterValue(filters.extracted_filters.period_to) &&
asksForUnresolvedInventorySupplierLink(userMessage) && messagePairAsksForUnresolvedInventorySupplierLink(userMessage, options.rawUserMessage) &&
!/(?:Р·Р°\s+РІСЃ[РµС]\s+время|Р·Р°\s+любоР\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage) !/(?:Р·Р°\s+РІСЃ[РµС]\s+время|Р·Р°\s+любоР\s+период|all[\s-]?time|all\s+periods?)/iu.test(userMessage)
) { ) {
const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date); const monthWindow = deriveMonthWindowForDate(filters.extracted_filters.as_of_date);
@ -3808,6 +3907,21 @@ export class AddressQueryService {
receivablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ??
vatPayableConfirmedExecution?.executionFilters ?? vatPayableConfirmedExecution?.executionFilters ??
filters.extracted_filters; filters.extracted_filters;
if (
intent.intent === "inventory_aging_by_purchase_date" &&
toNonEmptyFilterValue(executionFilters.as_of_date) &&
(toNonEmptyFilterValue(executionFilters.period_from) || toNonEmptyFilterValue(executionFilters.period_to))
) {
executionFilters = { ...executionFilters };
delete executionFilters.period_from;
delete executionFilters.period_to;
if (!filters.warnings.includes("period_window_detached_for_inventory_aging_execution")) {
filters.warnings.push("period_window_detached_for_inventory_aging_execution");
}
if (!baseReasons.includes("period_window_detached_for_inventory_aging_execution")) {
baseReasons.push("period_window_detached_for_inventory_aging_execution");
}
}
if ( if (
intent.intent === "counterparty_activity_lifecycle" && intent.intent === "counterparty_activity_lifecycle" &&
typeof executionFilters.counterparty === "string" && typeof executionFilters.counterparty === "string" &&
@ -3992,6 +4106,7 @@ export class AddressQueryService {
: typeof filterSet.account === "string" : typeof filterSet.account === "string"
? filterSet.account ? filterSet.account
: undefined, : undefined,
warehouseHint: typeof filterSet.warehouse === "string" ? filterSet.warehouse : undefined,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
@ -4703,7 +4818,11 @@ export class AddressQueryService {
); );
return { return {
handled: true, handled: true,
reply_text: repairCounterpartyReplyLabel(replyText, intent.intent, filters.extracted_filters), reply_text: repairCounterpartyReplyLabel(
ensureInventorySupplierOverlapLeadLine(intent.intent, replyText),
intent.intent,
filters.extracted_filters
),
reply_type: inferReplyType(responseType), reply_type: inferReplyType(responseType),
response_type: responseType, response_type: responseType,
debug: debugPayload debug: debugPayload
@ -4871,7 +4990,7 @@ export class AddressQueryService {
return { return {
handled: true, handled: true,
reply_text: repairCounterpartyReplyLabel( reply_text: repairCounterpartyReplyLabel(
input.replyText, ensureInventorySupplierOverlapLeadLine(intent.intent, input.replyText),
intent.intent, intent.intent,
(input.extractedFilters ?? filters.extracted_filters) as AddressFilterSet (input.extractedFilters ?? filters.extracted_filters) as AddressFilterSet
), ),
@ -5207,7 +5326,7 @@ export class AddressQueryService {
resultMode: broadenedCoverageEvidence.result_mode ?? undefined resultMode: broadenedCoverageEvidence.result_mode ?? undefined
}); });
return buildFactualExecutionResult({ return buildFactualExecutionResult({
replyText: injectNoticeAfterLeadLine(broadenedFactual.text, broadenedPrefix), replyText: injectAutoBroadenedPeriodNotice(intent.intent, broadenedFactual.text, broadenedPrefix),
responseType: broadenedFactual.responseType, responseType: broadenedFactual.responseType,
responseSemantics: broadenedFactual.semantics, responseSemantics: broadenedFactual.semantics,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id, selectedRecipe: broadenedSelection.selected_recipe.recipe_id,

View File

@ -1552,20 +1552,11 @@ function buildInventoryAgingByPurchaseDateQuery(filters: AddressFilterSet, resol
buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"]) buildInventoryItemReferenceCondition(filters, ["Остатки.Субконто1"])
]; ];
const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions); const onHandWhereClause = buildStaticWhereClause(onHandScopeConditions);
const currentStockItemSubquery = [
"(ВЫБРАТЬ РАЗЛИЧНЫЕ",
" Остатки.Субконто1",
" ИЗ",
` РегистрБухгалтерии.Хозрасчетный.Остатки(${asOfExpr}, , , ) КАК Остатки`,
buildStaticWhereClause(onHandScopeConditions, " "),
")"
].join("\n");
const purchaseWhereClause = buildStaticWhereClause([ const purchaseWhereClause = buildStaticWhereClause([
"Товары.Ссылка.Проведен = ИСТИНА", "Товары.Ссылка.Проведен = ИСТИНА",
`Товары.Ссылка.Дата <= ${asOfExpr}`, `Товары.Ссылка.Дата <= ${asOfExpr}`,
buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]), buildOrganizationReferenceCondition(filters, ["Товары.Ссылка.Организация"]),
buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]), buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"])
`Товары.Номенклатура В ${currentStockItemSubquery}`
]); ]);
return ` return `

View File

@ -693,6 +693,9 @@ function shouldRestoreInventoryRootFrame(
if (!canReenterInventoryRoot) { if (!canReenterInventoryRoot) {
return false; return false;
} }
if (intent === "inventory_aging_by_purchase_date") {
return false;
}
if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) { if (intent !== "unknown" && !isInventoryIntent(intent) && !hasInventoryRootRestatementCue) {
return false; return false;
} }
@ -723,20 +726,20 @@ function shouldRestoreInventoryRootFrame(
function hasSelectedObjectInventorySignal(text: string): boolean { function hasSelectedObjectInventorySignal(text: string): boolean {
const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? "")); const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? ""));
if ( if (
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
repairedSelectedObjectText repairedSelectedObjectText
) )
) { ) {
return true; return true;
} }
if ( if (
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
String(text ?? "") String(text ?? "")
) )
) { ) {
return true; return true;
} }
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( return /(?:по\s+выбранному\s+объекту|выбранн(?:ому|ым)\s+(?:объекту|товару|товаром|позиции|позицией)|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
String(text ?? "") String(text ?? "")
); );
} }
@ -775,6 +778,7 @@ export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean
/(?:по\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+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test( /(?:по\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+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test(
value value
) || ) ||
/(?:документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици)|какие\s+документ[а-яё]*[\s\S]{0,40}поступлен[а-яё]*[\s\S]{0,140}(?:выбранн|товар|позици))/iu.test(value) ||
/(?:по\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+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( /(?:по\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+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(
value value
) || ) ||
@ -1062,6 +1066,22 @@ function mergeFollowupFilters(
reasons.push("item_cleared_for_stock_slice_aging"); reasons.push("item_cleared_for_stock_slice_aging");
} }
}; };
const clearInventoryOnHandWarehouseAliasItem = (): void => {
if (intent !== "inventory_on_hand_as_of_date") {
return;
}
const currentItem = toNonEmptyString(merged.item);
const currentWarehouse = toNonEmptyString(merged.warehouse);
if (!currentItem || !currentWarehouse) {
return;
}
const normalizedItem = normalizeOrganizationScopeSearchText(currentItem);
const normalizedWarehouse = normalizeOrganizationScopeSearchText(currentWarehouse);
if (normalizedItem && normalizedWarehouse && normalizedItem === normalizedWarehouse) {
delete merged.item;
reasons.push("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot");
}
};
if (!followupContext) { if (!followupContext) {
if ( if (
(intent === "list_open_contracts" || (intent === "list_open_contracts" ||
@ -1081,18 +1101,21 @@ function mergeFollowupFilters(
); );
} }
} }
clearInventoryOnHandWarehouseAliasItem();
clearInventoryAgingOrganizationAliasItem(null); clearInventoryAgingOrganizationAliasItem(null);
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
const previous = followupContext.previous_filters ?? {}; const previous = followupContext.previous_filters ?? {};
const root = followupContext.root_filters ?? {};
const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value); const previousAnchorValue = toNonEmptyString(followupContext.previous_anchor_value);
const previousCounterparty = toNonEmptyString(previous.counterparty); const previousCounterparty = toNonEmptyString(previous.counterparty);
const previousContract = toNonEmptyString(previous.contract); const previousContract = toNonEmptyString(previous.contract);
const previousAccount = toNonEmptyString(previous.account); const previousAccount = toNonEmptyString(previous.account);
const previousItem = toNonEmptyString(previous.item); const previousItem = toNonEmptyString(previous.item);
const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null; const previousAnchorItem = followupContext.previous_anchor_type === "item" ? previousAnchorValue : null;
const previousOrganization = toNonEmptyString(previous.organization); const previousOrganization = toNonEmptyString(previous.organization) ?? toNonEmptyString(root.organization);
const previousWarehouse = toNonEmptyString(previous.warehouse) ?? toNonEmptyString(root.warehouse);
const previousAsOfDate = toNonEmptyString(previous.as_of_date); const previousAsOfDate = toNonEmptyString(previous.as_of_date);
const previousPeriodFrom = toNonEmptyString(previous.period_from); const previousPeriodFrom = toNonEmptyString(previous.period_from);
const previousPeriodTo = toNonEmptyString(previous.period_to); const previousPeriodTo = toNonEmptyString(previous.period_to);
@ -1539,6 +1562,10 @@ function mergeFollowupFilters(
reasons.push("period_derived_from_inventory_root_frame_year"); reasons.push("period_derived_from_inventory_root_frame_year");
} }
if (intent === "inventory_aging_by_purchase_date") { if (intent === "inventory_aging_by_purchase_date") {
if (previousWarehouse && !toNonEmptyString(merged.warehouse)) {
merged.warehouse = previousWarehouse;
reasons.push("warehouse_from_followup_context");
}
clearInventoryAgingOrganizationAliasItem(previousOrganization); clearInventoryAgingOrganizationAliasItem(previousOrganization);
} }
if ( if (
@ -1565,6 +1592,7 @@ function mergeFollowupFilters(
delete merged.period_to; delete merged.period_to;
reasons.push("period_cleared_by_all_time_followup"); reasons.push("period_cleared_by_all_time_followup");
} }
clearInventoryOnHandWarehouseAliasItem();
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
@ -1755,6 +1783,7 @@ function mergeFollowupFilters(
} }
} }
clearInventoryOnHandWarehouseAliasItem();
return { filters: merged, reasons }; return { filters: merged, reasons };
} }
@ -1788,18 +1817,56 @@ function deriveIntentWithFollowupContext(
userMessage: string, userMessage: string,
followupContext: AddressFollowupContext | null followupContext: AddressFollowupContext | null
): AddressIntentResolution { ): AddressIntentResolution {
const normalizedMessage = String(userMessage ?? "");
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) { if (!followupContext || (!followupContext.previous_intent && !followupContext.target_intent)) {
if (hasSelectedObjectReference && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage)) {
if (
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date"
) {
return {
intent: "inventory_purchase_documents_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_documents"]
};
}
}
if (hasSelectedObjectReference && hasInventorySupplierFollowupCue(normalizedMessage)) {
if (
detectedIntent.intent === "unknown" ||
detectedIntent.intent === "list_documents_by_counterparty" ||
detectedIntent.intent === "list_documents_by_contract" ||
detectedIntent.intent === "inventory_on_hand_as_of_date"
) {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_supplier"]
};
}
}
if (hasSelectedObjectReference && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) {
if (detectedIntent.intent === "unknown" || detectedIntent.intent === "inventory_on_hand_as_of_date") {
return {
intent: "inventory_purchase_provenance_for_item",
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_inline_selected_object_inventory_purchase_date"]
};
}
}
return detectedIntent; return detectedIntent;
} }
const normalizedMessage = String(userMessage ?? "");
const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage); const genericFollowupSignal = hasAddressFollowupContextSignal(normalizedMessage);
const previousFilters = followupContext.previous_filters ?? {}; const previousFilters = followupContext.previous_filters ?? {};
const purchaseBridgeContinuationSignal = const purchaseBridgeContinuationSignal =
followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" && followupContext.previous_intent === "vat_liability_confirmed_for_tax_period" &&
Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) && Boolean(toNonEmptyString(previousFilters.purchase_date_bridge_selected)) &&
hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage); hasInventoryPurchaseDateVatBridgeContinuationCue(normalizedMessage);
const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal; const hasFollowupSignal = genericFollowupSignal || purchaseBridgeContinuationSignal || hasSelectedObjectReference;
if (!hasFollowupSignal) { if (!hasFollowupSignal) {
return detectedIntent; return detectedIntent;
} }
@ -1834,7 +1901,6 @@ function deriveIntentWithFollowupContext(
followupContext.root_anchor_type === "item" || followupContext.root_anchor_type === "item" ||
followupContext.current_frame_kind === "inventory_root" || followupContext.current_frame_kind === "inventory_root" ||
followupContext.current_frame_kind === "inventory_drilldown"; followupContext.current_frame_kind === "inventory_drilldown";
const hasSelectedObjectReference = hasSelectedObjectInventorySignal(normalizedMessage);
const inventorySelectedObjectFollowup = const inventorySelectedObjectFollowup =
inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal)); inventoryLineageActive && (hasSelectedObjectReference || (previousIsInventoryFamily && hasFollowupSignal));
const previousCounterpartyLaneActive = const previousCounterpartyLaneActive =

View File

@ -14,7 +14,11 @@ import type { ComposeStageRow } from "./composeStage";
interface InventoryComposeOptions { interface InventoryComposeOptions {
userMessage?: string; userMessage?: string;
rawUserMessage?: string;
itemHint?: string; itemHint?: string;
counterpartyHint?: string;
accountHint?: string;
warehouseHint?: string;
asOfDate?: string; asOfDate?: string;
periodFrom?: string; periodFrom?: string;
periodTo?: string; periodTo?: string;
@ -134,6 +138,60 @@ function inventoryPartyListOrUnknown(parties: string[]): string {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем";
} }
function inventoryPartyLeadList(parties: string[], limit = 6): string {
const visible = parties.slice(0, limit);
const remaining = parties.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function inventoryItemLeadList(items: string[], limit = 6): string {
const visible = items.slice(0, limit);
const remaining = items.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function collectInventoryTraceItems(rows: ComposeStageRow[]): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const row of rows) {
const item = String(row.item ?? "").trim();
if (!item) {
continue;
}
const comparable = normalizeInventoryReplyEntityToken(item);
if (!comparable || seen.has(comparable)) {
continue;
}
seen.add(comparable);
result.push(item);
}
return result;
}
function extractInventoryCounterpartiesFromEvidenceLines(lines: string[]): string[] {
const counterparties: string[] = [];
for (const line of lines) {
const match = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|]+)/iu.exec(line);
const value = String(match?.[1] ?? "").replace(/\s+/gu, " ").trim();
if (value) {
counterparties.push(value);
}
}
return counterparties;
}
function hasSupplierScopedItemListQuestion(options: InventoryComposeOptions): boolean {
if (!String(options.counterpartyHint ?? "").trim()) {
return false;
}
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
return /(?:какие?\s+товар|товар(?:ы|ов)?\s+(?:от|у)\s+поставщик|позици(?:и|я|ю)|номенклатур|лежат|остат(?:ок|ки|ка|ков))/iu.test(
text
);
}
function omitAmountFromInventoryTraceEvidence(lines: string[]): string[] { function omitAmountFromInventoryTraceEvidence(lines: string[]): string[] {
return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, "")); return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, ""));
} }
@ -216,6 +274,43 @@ function sumInventoryRowQuantity(rows: ComposeStageRow[]): number {
return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0); return rows.reduce((sum, row) => sum + (typeof row.quantity === "number" && Number.isFinite(row.quantity) ? row.quantity : 0), 0);
} }
function cleanInventoryScopeHint(value: string | null | undefined): string | null {
const cleaned = String(value ?? "").replace(/\s+/gu, " ").trim();
return cleaned.length > 0 ? cleaned : null;
}
function inventoryOnHandAccountScopeLabel(options: InventoryComposeOptions): string | null {
const accountHint = cleanInventoryScopeHint(options.accountHint);
if (accountHint) {
return `по счету ${accountHint}`;
}
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
return /(?:^|[^\d])41(?:[.,]\d+)?(?:[^\d]|$)|41\s*сч[её]т/iu.test(text) ? "по счету 41" : null;
}
function inventoryOnHandWarehouseScopeLabel(
options: InventoryComposeOptions,
uniqueWarehouses: string[]
): string | null {
const warehouseHint = cleanInventoryScopeHint(options.warehouseHint);
if (warehouseHint) {
return `по складу ${warehouseHint}`;
}
const text = `${options.rawUserMessage ?? ""} ${options.userMessage ?? ""}`;
if (uniqueWarehouses.length === 1 && /(?:по|на)\s+склад[уе]?/iu.test(text)) {
return `по складу ${uniqueWarehouses[0]}`;
}
return null;
}
function inventoryOnHandScopePhrase(options: InventoryComposeOptions, uniqueWarehouses: string[]): string {
const scopes = [
inventoryOnHandAccountScopeLabel(options),
inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses)
].filter((item): item is string => Boolean(item));
return scopes.length > 0 ? scopes.join(" и ") : "на складе";
}
function formatInventoryPercent(value: number | null, formatNumberWithDots: (value: number, fractionDigits?: number) => string): string { function formatInventoryPercent(value: number | null, formatNumberWithDots: (value: number, fractionDigits?: number) => string): string {
return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`; return value === null || !Number.isFinite(value) ? "не подтверждена" : `${formatNumberWithDots(value, 2)}%`;
} }
@ -354,10 +449,11 @@ export function composeInventoryReply(
positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0) positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)
); );
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const scopePhrase = inventoryOnHandScopePhrase(options, uniqueWarehouses);
const directAnswerLine = const directAnswerLine =
positions.length > 0 positions.length > 0
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.` ? `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; : `На ${deps.formatDateRu(asOfDate)} ${scopePhrase} подтвержденных товарных остатков не найдено.`;
const lines: string[] = [directAnswerLine]; const lines: string[] = [directAnswerLine];
if (positions.length > 0) { if (positions.length > 0) {
@ -392,13 +488,22 @@ export function composeInventoryReply(
]); ]);
} }
appendInventoryBulletSection(lines, "Сводка:", [ const summaryBullets = [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`, `Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, `Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, `Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции." "Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
]); ];
const accountScope = inventoryOnHandAccountScopeLabel(options);
const warehouseScope = inventoryOnHandWarehouseScopeLabel(options, uniqueWarehouses);
if (accountScope) {
summaryBullets.splice(1, 0, `Срез ${accountScope}.`);
}
if (warehouseScope) {
summaryBullets.splice(accountScope ? 2 : 1, 0, `Срез ${warehouseScope}.`);
}
appendInventoryBulletSection(lines, "Сводка:", summaryBullets);
if (rows.length !== positions.length) { if (rows.length !== positions.length) {
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`); lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
} }
@ -561,22 +666,50 @@ export function composeInventoryReply(
const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row));
const summary = deps.summarizeInventoryTraceRows(purchaseRows); const summary = deps.summarizeInventoryTraceRows(purchaseRows);
normalizeInventoryTraceSummaryCounterparties(summary); normalizeInventoryTraceSummaryCounterparties(summary);
const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); const purchaseRowCounterparties = purchaseRows.map((row) =>
deps.uniqueStrings([
...deps.extractInventoryCounterpartyCandidates(row),
...extractInventoryCounterpartiesFromEvidenceLines(deps.formatInventoryTraceRows([row], 1))
])
);
const observedCounterparties = deps.uniqueStrings([
...summary.counterparties,
...purchaseRowCounterparties.flat()
]);
const unresolvedRows = purchaseRows.filter((_, index) => (purchaseRowCounterparties[index]?.length ?? 0) === 0);
const unresolvedSupplierQuestionText = `${String(options.userMessage ?? "")}\n${String(options.rawUserMessage ?? "")}`;
const unresolvedSupplierQuestion = const unresolvedSupplierQuestion =
/(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test( /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(
String(options.userMessage ?? "") unresolvedSupplierQuestionText
); );
if (unresolvedSupplierQuestion) { if (unresolvedSupplierQuestion) {
const unresolvedItems = collectInventoryTraceItems(unresolvedRows);
const unresolvedItemPreviewLimit = 3;
const unresolvedDocumentPreviewLimit = 2;
const directAnswerLine = const directAnswerLine =
unresolvedRows.length > 0 unresolvedRows.length > 0
? `В текущем складском срезе найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.` ? unresolvedItems.length > 0
? `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено ${deps.formatNumberWithDots(unresolvedItems.length)} позиций без надежной привязки к поставщику; первые примеры ниже.`
: `В текущем складском срезе на ${deps.formatDateRu(asOfDate)} найдено операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`
: "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены."; : "В текущем складском срезе товары без явно выделенной привязки к поставщику в доступных данных не найдены.";
const lines: string[] = [directAnswerLine]; const lines: string[] = [directAnswerLine];
if (unresolvedItems.length > 0) {
appendInventorySection(
lines,
"Позиции без надежной привязки к поставщику:",
unresolvedItems.slice(0, unresolvedItemPreviewLimit).map((item, index) => `${index + 1}. ${item}`)
);
if (unresolvedItems.length > unresolvedItemPreviewLimit) {
lines.push(
`- Показаны первые ${unresolvedItemPreviewLimit} из ${deps.formatNumberWithDots(unresolvedItems.length)} таких позиций.`
);
}
}
appendInventoryBulletSection(lines, "Что проверили:", [ appendInventoryBulletSection(lines, "Что проверили:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`, `Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`,
`Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`, `Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`,
`Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(summary.counterparties.length)}.` `Поставщиков, выделенных в остальных операциях: ${deps.formatNumberWithDots(observedCounterparties.length)}.`
]); ]);
appendInventoryBulletSection(lines, "Ограничения:", [ appendInventoryBulletSection(lines, "Ограничения:", [
"Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии." "Без партионного учета это проверка доступного закупочного следа по складскому срезу, а не юридическое доказательство владельца каждой партии."
@ -584,11 +717,16 @@ export function composeInventoryReply(
if (unresolvedRows.length > 0) { if (unresolvedRows.length > 0) {
appendInventorySection( appendInventorySection(
lines, lines,
"Позиции без явно выделенного поставщика:", "Опорные документы:",
deps.formatInventoryTraceRows(unresolvedRows, 12) deps.formatInventoryTraceRows(unresolvedRows, unresolvedDocumentPreviewLimit)
); );
} else if (summary.counterparties.length > 0) { if (unresolvedRows.length > unresolvedDocumentPreviewLimit) {
lines.push(`- В доступном закупочном следе встречаются поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); lines.push(
`- Показаны первые ${unresolvedDocumentPreviewLimit} из ${deps.formatNumberWithDots(unresolvedRows.length)} строк без выделенного поставщика.`
);
}
} else if (observedCounterparties.length > 0) {
lines.push(`- В доступном закупочном следе встречаются поставщики: ${observedCounterparties.slice(0, 6).join("; ")}.`);
} }
return buildFactualSummaryReply( return buildFactualSummaryReply(
lines, lines,
@ -596,35 +734,100 @@ export function composeInventoryReply(
); );
} }
const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; const warehouseLabel = summary.warehouses[0] ?? "не указанного склада";
const purchasePeriodLabel =
summary.firstPeriod && summary.lastPeriod
? `${deps.inventoryTraceDateLabel(summary.firstPeriod)}..${deps.inventoryTraceDateLabel(summary.lastPeriod)}`
: "даты закупки не выделены";
const supplierScopedItems = collectInventoryTraceItems(purchaseRows);
const supplierScopedItemQuestion = hasSupplierScopedItemListQuestion(options);
if (supplierScopedItemQuestion) {
const requestedSupplierLabel = summary.counterparties[0] ?? String(options.counterpartyHint ?? "").trim();
const directAnswerLine =
supplierScopedItems.length > 0
? `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} подтверждены позиции: ${inventoryItemLeadList(supplierScopedItems)}.`
: `По поставщику ${requestedSupplierLabel} в складском срезе ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} позиции не подтверждены в доступном закупочном следе.`;
const lines: string[] = [directAnswerLine];
if (supplierScopedItems.length > 0) {
appendInventorySection(
lines,
"Позиции:",
supplierScopedItems.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)
);
if (supplierScopedItems.length > 8) {
lines.push(`- Показаны первые 8 из ${deps.formatNumberWithDots(supplierScopedItems.length)} позиций.`);
}
}
appendInventoryBulletSection(lines, "Что проверили:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Период найденного закупочного следа: ${purchasePeriodLabel}.`,
`Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
]);
appendInventoryBulletSection(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
]);
if (observedCounterparties.length > 0) {
lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
}
if (purchaseRows.length > 0) {
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(
lines,
buildConfirmedBalanceSemantics(
purchaseRows.length > 0 ? (supplierScopedItems.length > 0 ? "strong" : "medium") : "medium",
purchaseRows.length > 0
)
);
}
const directAnswerLine = const directAnswerLine =
summary.counterparties.length === 1 observedCounterparties.length === 1
? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} выявлен поставщик закупочного следа: ${observedCounterparties[0]}.`
: summary.counterparties.length > 1 : observedCounterparties.length > 1
? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` ? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} найдено несколько поставщиков закупочного следа: ${inventoryPartyLeadList(observedCounterparties)}.`
: `По складскому остатку ${warehouseLabel} поставщик в доступных данных не выделен.`; : unresolvedRows.length > 0
? `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначный поставщик текущего остатка не подтвержден: в ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операциях поставщик не выделен.`
: `По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} поставщик в доступных данных не выделен.`;
const lines: string[] = [directAnswerLine]; const lines: string[] = [directAnswerLine];
if (unresolvedRows.length > 0) {
lines.push(
`По складскому остатку ${warehouseLabel} на ${deps.formatDateRu(asOfDate)} однозначная атрибуция части текущего остатка не подтверждена: ${deps.formatNumberWithDots(unresolvedRows.length)} закупочных операций без явно выделенного поставщика.`
);
}
appendInventoryBulletSection(lines, "Что проверили:", [ appendInventoryBulletSection(lines, "Что проверили:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,
`Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, `Период найденного закупочного следа: ${purchasePeriodLabel}.`,
`Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, `Закупочных документов / операций в выборке: ${deps.formatNumberWithDots(summary.documents.length)} / ${deps.formatNumberWithDots(purchaseRows.length)}.`
`Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`,
`Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`
]); ]);
appendInventoryBulletSection(lines, "Ограничения:", [ appendInventoryBulletSection(lines, "Ограничения:", [
"Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии." "Без партионного учета этот ответ показывает закупочный след текущего остатка, но не доказывает владельца каждой конкретной партии."
]); ]);
if (summary.counterparties.length > 0) { if (observedCounterparties.length > 0) {
lines.push(`- Найденные поставщики: ${summary.counterparties.slice(0, 6).join("; ")}.`); lines.push(`- Найденные поставщики: ${inventoryPartyLeadList(observedCounterparties)}.`);
} else if (purchaseRows.length > 0) { } else if (purchaseRows.length > 0) {
lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных."); lines.push("- Закупочные движения найдены, но поставщик не выделен отдельным полем в доступных данных.");
} else { } else {
lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу."); lines.push("- В доступных данных не найдено закупочных движений по выбранному складскому срезу.");
} }
if (unresolvedRows.length > 0) {
lines.push(`- Операций без явно выделенного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`);
}
if (purchaseRows.length > 0) { if (purchaseRows.length > 0) {
appendInventorySection(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 10)); 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,
@ -1025,12 +1228,16 @@ export function composeInventoryReply(
const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties); const supplierMatches = inventoryRequestedPartyMatches(requestedSupplier, purchaseSummary.counterparties);
const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties); const buyerMatches = inventoryRequestedPartyMatches(requestedBuyer, saleSummary.counterparties);
const mismatchParts: string[] = []; const mismatchParts: string[] = [];
if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) { if (requestedSupplier && purchaseRows.length === 0) {
mismatchParts.push(`закупка у запрошенного поставщика ${requestedSupplier} не найдена`);
} else if (requestedSupplier && purchaseRows.length > 0 && !supplierMatches) {
mismatchParts.push( mismatchParts.push(
`запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}` `запрошенный поставщик ${requestedSupplier} не совпал с найденным поставщиком: ${inventoryPartyListOrUnknown(purchaseSummary.counterparties)}`
); );
} }
if (requestedBuyer && saleRows.length > 0 && !buyerMatches) { if (requestedBuyer && saleRows.length === 0) {
mismatchParts.push(`продажа/выбытие запрошенному покупателю ${requestedBuyer} не найдены`);
} else if (requestedBuyer && saleRows.length > 0 && !buyerMatches) {
mismatchParts.push( mismatchParts.push(
`запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}` `запрошенный покупатель ${requestedBuyer} не совпал с найденным покупателем: ${inventoryPartyListOrUnknown(saleSummary.counterparties)}`
); );
@ -1038,9 +1245,15 @@ export function composeInventoryReply(
const directAnswerLine = const directAnswerLine =
mismatchParts.length > 0 mismatchParts.length > 0
? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.` ? `Запрошенная цепочка по товару ${itemLabel} полностью не подтверждена: ${mismatchParts.join("; ")}.`
: purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 : purchaseRows.length > 0 && saleRows.length > 0 && purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1
? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.`
: `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; : purchaseRows.length > 0 && saleRows.length > 0
? `По товару ${itemLabel} найдены обе стороны цепочки поставки и продажи; участников несколько или они неоднозначны, детали ниже.`
: purchaseRows.length > 0
? `По товару ${itemLabel} закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.`
: saleRows.length > 0
? `По товару ${itemLabel} найдена продажа/выбытие со счета 41.01, но закупочная часть цепочки в доступных данных не подтверждена.`
: `По товару ${itemLabel} цепочка поставки и продажи не подтверждена: движений по счету 41.01 в доступных данных не найдено.`;
const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; const lines: string[] = [directAnswerLine, "", "Подтверждение:"];
lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); lines.push(`- Строк закупки на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`);
lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); lines.push(`- Строк продажи со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`);

View File

@ -7,6 +7,7 @@ export interface ComposeFactualReplyOptions<TVatDirectSourceProbe = unknown> {
counterpartyHint?: string; counterpartyHint?: string;
organizationHint?: string; organizationHint?: string;
accountHint?: string; accountHint?: string;
warehouseHint?: string;
periodFrom?: string; periodFrom?: string;
periodTo?: string; periodTo?: string;
asOfDate?: string; asOfDate?: string;

View File

@ -4,7 +4,11 @@ import type {
AddressLlmSemanticHints, AddressLlmSemanticHints,
AddressSemanticFrame AddressSemanticFrame
} from "../../types/addressQuery"; } from "../../types/addressQuery";
import { isInventoryItemAnchorDegradation, isLowQualityInventoryItemAnchorValue } from "../addressFilterExtractor"; import {
isInventoryItemAnchorDegradation,
isLowQualityCounterpartyAnchorValue,
isLowQualityInventoryItemAnchorValue
} from "../addressFilterExtractor";
function toNonEmptyString(value: unknown): string | null { function toNonEmptyString(value: unknown): string | null {
if (value === null || value === undefined) { if (value === null || value === undefined) {
@ -162,11 +166,16 @@ export function applyAddressLlmSemanticHintsToExtraction(
} }
if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) { if (semanticHints.scope_target_kind === "counterparty" && scopeTargetText) {
extractedFilters.counterparty = scopeTargetText; if (isLowQualityCounterpartyAnchorValue(scopeTargetText)) {
pushWarning(warnings, "counterparty_from_llm_semantics"); delete extractedFilters.counterparty;
semanticFrame.scope_kind = "explicit_anchor"; pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
semanticFrame.anchor_kind = "counterparty"; } else {
semanticFrame.anchor_value = scopeTargetText; extractedFilters.counterparty = scopeTargetText;
pushWarning(warnings, "counterparty_from_llm_semantics");
semanticFrame.scope_kind = "explicit_anchor";
semanticFrame.anchor_kind = "counterparty";
semanticFrame.anchor_value = scopeTargetText;
}
} }
if (semanticHints.scope_target_kind === "contract" && scopeTargetText) { if (semanticHints.scope_target_kind === "contract" && scopeTargetText) {
@ -189,6 +198,13 @@ export function applyAddressLlmSemanticHintsToExtraction(
pushWarning(warnings, "item_llm_semantics_ignored"); pushWarning(warnings, "item_llm_semantics_ignored");
} }
} }
if (
toNonEmptyString(extractedFilters.counterparty) &&
isLowQualityCounterpartyAnchorValue(String(extractedFilters.counterparty))
) {
delete extractedFilters.counterparty;
pushWarning(warnings, "counterparty_cleared_low_quality_llm_semantics");
}
return { return {
...extraction, ...extraction,

View File

@ -76,6 +76,222 @@ function normalizeAddressReplyType(value: unknown): AssistantReplyType {
return value === "factual" || value === "partial_coverage" ? value : "partial_coverage"; return value === "factual" || value === "partial_coverage" ? value : "partial_coverage";
} }
function uniqueNormalizedStrings(values: Array<string | null | undefined>): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const text = String(value ?? "").trim();
if (!text) {
continue;
}
const key = text.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(text);
}
return result;
}
function extractInventorySupplierOverlapCounterpartiesFromReply(replyText: string): string[] {
const values: string[] = [];
const pattern = /\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442:\s*([^|\r\n]+)/giu;
for (const match of replyText.matchAll(pattern)) {
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
if (!value || /^(?:\u043d\u0435\s+\u0432\u044b\u0434\u0435\u043b\u0435\u043d|\u043d\/\u0434|unknown)$/iu.test(value)) {
continue;
}
values.push(value);
}
return uniqueNormalizedStrings(values);
}
function formatInventorySupplierOverlapCounterpartyLead(counterparties: string[], limit = 6): string {
const visible = counterparties.slice(0, limit);
const remaining = counterparties.length - visible.length;
const suffix = remaining > 0 ? `; и еще ${remaining}` : "";
return `${visible.join("; ")}${suffix}`;
}
function hasInventorySupplierOverlapDefensiveLead(firstLine: string): boolean {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
const startsWithStockScope = /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(
normalized
);
const hasDefensiveAttribution = /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(
normalized
);
return startsWithStockScope && hasDefensiveAttribution;
}
function firstNonEmptyReplyLine(text: unknown): string {
return String(text ?? "")
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? "";
}
function hasInventorySupplierOverlapCounterpartyLead(firstLine: string): boolean {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
return (
/^\u043f\u043e\s+\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443/u.test(normalized) &&
/\u043d\u0430\u0439\u0434\u0435\u043d/u.test(normalized) &&
/\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a/u.test(normalized) &&
/\u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u043e\u0433\u043e\s+\u0441\u043b\u0435\u0434\u0430/u.test(normalized)
);
}
function preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize(input: {
rawReply: string;
sanitizedReply: string;
addressDebug: unknown;
}): { text: string; applied: boolean } {
const debug = toRecordObject(input.addressDebug);
const detectedIntent = toNullableString(debug?.detected_intent);
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
return { text: input.sanitizedReply, applied: false };
}
const rawLead = firstNonEmptyReplyLine(input.rawReply);
if (!hasInventorySupplierOverlapCounterpartyLead(rawLead)) {
return { text: input.sanitizedReply, applied: false };
}
const sanitizedLead = firstNonEmptyReplyLine(input.sanitizedReply);
if (hasInventorySupplierOverlapCounterpartyLead(sanitizedLead)) {
return { text: input.sanitizedReply, applied: false };
}
const sanitizedText = String(input.sanitizedReply ?? "").trim();
return {
text: sanitizedText ? [rawLead, sanitizedText].join("\n\n") : rawLead,
applied: true
};
}
function inspectInventorySupplierOverlapDefensiveLead(firstLine: string): {
stockScopeMatch: boolean;
defensiveMatch: boolean;
} {
const normalized = String(firstLine ?? "").toLocaleLowerCase("ru-RU").replace(/\u0451/gu, "\u0435");
return {
stockScopeMatch: /^\u043f\u043e\s+(?:\u0441\u043a\u043b\u0430\u0434\u0441\u043a\u043e\u043c\u0443\s+\u043e\u0441\u0442\u0430\u0442\u043a\u0443|\u043e\u043a\u043d\u0443)/u.test(
normalized
),
defensiveMatch: /\u043e\u0434\u043d\u043e\u0437\u043d\u0430\u0447\u043d(?:\u044b\u0439\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0430\u044f\s+\u0430\u0442\u0440\u0438\u0431\u0443\u0446\u0438\u044f).*?\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d/u.test(
normalized
)
};
}
function buildInventorySupplierOverlapSurfaceRepairAudit(input: {
applied: boolean;
detectedIntent: string | null;
reasonCodes: string[];
counterparties?: string[];
firstLineSample?: string;
stockScopeMatch?: boolean;
defensiveMatch?: boolean;
}): Record<string, unknown> {
return {
schema_version: "inventory_supplier_overlap_surface_repair_v1",
applied: input.applied,
detected_intent: input.detectedIntent,
counterparties: input.counterparties ?? [],
first_line_sample: input.firstLineSample ?? null,
stock_scope_match: input.stockScopeMatch ?? null,
defensive_match: input.defensiveMatch ?? null,
reason_codes: input.reasonCodes
};
}
function repairInventorySupplierOverlapSurfaceReply(
replyText: unknown,
addressDebug: unknown
): { text: string; audit: Record<string, unknown> | null } {
const text = String(replyText ?? "");
const debug = toRecordObject(addressDebug);
const detectedIntent = toNullableString(debug?.detected_intent);
if (detectedIntent !== "inventory_supplier_stock_overlap_as_of_date") {
return {
text,
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: false,
detectedIntent,
reasonCodes: ["inventory_supplier_overlap_surface_repair_intent_not_applicable"]
})
};
}
const lines = text.split(/\r?\n/u);
const firstLine = lines[0]?.trim() ?? "";
const leadInspection = inspectInventorySupplierOverlapDefensiveLead(firstLine);
if (!hasInventorySupplierOverlapDefensiveLead(firstLine)) {
return {
text,
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: false,
detectedIntent,
firstLineSample: firstLine.slice(0, 220),
stockScopeMatch: leadInspection.stockScopeMatch,
defensiveMatch: leadInspection.defensiveMatch,
reasonCodes: ["inventory_supplier_overlap_surface_repair_first_line_not_defensive"]
})
};
}
const counterparties = extractInventorySupplierOverlapCounterpartiesFromReply(text);
if (counterparties.length === 0) {
return {
text,
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: false,
detectedIntent,
counterparties,
firstLineSample: firstLine.slice(0, 220),
stockScopeMatch: leadInspection.stockScopeMatch,
defensiveMatch: leadInspection.defensiveMatch,
reasonCodes: ["inventory_supplier_overlap_surface_repair_no_counterparties"]
})
};
}
const repairedLead =
counterparties.length === 1
? `По складскому остатку найден поставщик закупочного следа: ${counterparties[0]}.`
: `По складскому остатку найдено несколько поставщиков закупочного следа: ${formatInventorySupplierOverlapCounterpartyLead(counterparties)}.`;
return {
text: [repairedLead, firstLine, ...lines.slice(1)].join("\n"),
audit: buildInventorySupplierOverlapSurfaceRepairAudit({
applied: true,
detectedIntent,
counterparties,
firstLineSample: firstLine.slice(0, 220),
stockScopeMatch: leadInspection.stockScopeMatch,
defensiveMatch: leadInspection.defensiveMatch,
reasonCodes: ["inventory_supplier_overlap_surface_repair_applied"]
})
};
}
function extractSingleWarehouseScopeFromReply(replyText: string): string | null {
const values: string[] = [];
const normalized = String(replyText ?? "");
const byFieldPattern = /(?:^|\|)\s*склад\s*:\s*([^|\r\n]+)/giu;
for (const match of normalized.matchAll(byFieldPattern)) {
const value = toNullableString(match[1]?.replace(/[.;,\s]+$/u, ""));
if (value) {
values.push(value);
}
}
const fieldUnique = uniqueNormalizedStrings(values);
if (fieldUnique.length > 0) {
return fieldUnique.length === 1 ? fieldUnique[0] : null;
}
const leadMatch = normalized.match(/(?:по|на)\s+склад[уе]?\s+([^.,;\r\n]+)/iu);
const leadWarehouse = toNullableString(leadMatch?.[1]?.replace(/[.;,\s]+$/u, ""));
if (leadWarehouse) {
values.push(leadWarehouse);
}
const unique = uniqueNormalizedStrings(values);
return unique.length === 1 ? unique[0] : null;
}
function sameBusinessLabel(left: unknown, right: unknown): boolean { function sameBusinessLabel(left: unknown, right: unknown): boolean {
const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); const normalizedLeft = toNullableString(left)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); const normalizedRight = toNullableString(right)?.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
@ -115,7 +331,10 @@ function cleanComparisonScopeCompanyLine(line: string, organization: string | nu
.replace(/\bcompany-level\b/giu, "общий по компании") .replace(/\bcompany-level\b/giu, "общий по компании")
.replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез"); .replace(/\breusable bundle\b/giu, "сохраненный подтвержденный срез");
if (organization) { if (organization) {
clean = clean.replace(/по компании\s+Альтернатива Плюс/iu, `по компании ${organization}`); clean = clean.replace(
/по компании\s+.+?(?=\s+(?:подтвержден|подтвержд[её]н|зафиксирован|показан|есть)\b|:|$)/iu,
`по компании ${organization}`
);
} }
return clean.trim(); return clean.trim();
} }
@ -136,9 +355,7 @@ function buildComparisonScopeProofReply(input: {
session: unknown; session: unknown;
userMessage?: unknown; userMessage?: unknown;
}): { reply: string; audit: Record<string, unknown> } | null { }): { reply: string; audit: Record<string, unknown> } | null {
if (!hasComparisonScopeProofCue(input.userMessage)) { const legalOrganizationFromCurrentTurn = legalOrganizationLabelFromClarification(input.userMessage);
return null;
}
const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1); const entryPoint = toRecordObject(input.debug.assistant_mcp_discovery_entry_point_v1);
const turnInput = toRecordObject(entryPoint?.turn_input); const turnInput = toRecordObject(entryPoint?.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
@ -146,6 +363,9 @@ function buildComparisonScopeProofReply(input: {
if (!isBusinessOverview) { if (!isBusinessOverview) {
return null; return null;
} }
if (!hasComparisonScopeProofCue(input.userMessage) && !legalOrganizationFromCurrentTurn) {
return null;
}
const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates) const separateCandidates = Array.isArray(turnMeaning?.business_overview_separate_entity_candidates)
? turnMeaning.business_overview_separate_entity_candidates ? turnMeaning.business_overview_separate_entity_candidates
: []; : [];
@ -172,7 +392,7 @@ function buildComparisonScopeProofReply(input: {
if (!incomingAmount && !outgoingAmount && !netAmount) { if (!incomingAmount && !outgoingAmount && !netAmount) {
return null; return null;
} }
const organization = legalOrganizationLabelFromClarification(input.userMessage) const organization = legalOrganizationFromCurrentTurn
?? toNullableString(turnMeaning?.explicit_organization_scope) ?? toNullableString(turnMeaning?.explicit_organization_scope)
?? toNullableString(toRecordObject(comparisonScope?.organization)?.label); ?? toNullableString(toRecordObject(comparisonScope?.organization)?.label);
const documentCount = Number(toRecordObject(documentBundle)?.document_count); const documentCount = Number(toRecordObject(documentBundle)?.document_count);
@ -355,8 +575,24 @@ export function runAssistantAddressLaneResponseRuntime<ResponseType = AssistantM
input: RunAssistantAddressLaneResponseRuntimeInput<ResponseType> input: RunAssistantAddressLaneResponseRuntimeInput<ResponseType>
): RunAssistantAddressLaneResponseRuntimeOutput<ResponseType> { ): RunAssistantAddressLaneResponseRuntimeOutput<ResponseType> {
const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? finalizeAssistantAddressTurn; const finalizeAddressTurnSafe = input.finalizeAddressTurn ?? finalizeAssistantAddressTurn;
const safeAddressReply = input.sanitizeOutgoingAssistantText(input.addressLane.reply_text);
const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta); const debug = input.buildAddressDebugPayload(input.addressLane.debug, input.llmPreDecomposeMeta);
const surfaceRepair = repairInventorySupplierOverlapSurfaceReply(
input.addressLane.reply_text,
debug
);
const safeAddressReplyBase = input.sanitizeOutgoingAssistantText(surfaceRepair.text);
const preservedAddressReply = preserveInventorySupplierOverlapCounterpartyLeadAfterSanitize({
rawReply: surfaceRepair.text,
sanitizedReply: safeAddressReplyBase,
addressDebug: debug
});
const safeAddressReply = preservedAddressReply.text;
if (surfaceRepair.audit) {
debug.inventory_supplier_overlap_surface_repair_v1 = {
...surfaceRepair.audit,
lead_preserved_after_sanitize: preservedAddressReply.applied
};
}
const followupOffer = input.buildAddressFollowupOffer(debug); const followupOffer = input.buildAddressFollowupOffer(debug);
if (followupOffer) { if (followupOffer) {
debug.address_followup_offer = followupOffer; debug.address_followup_offer = followupOffer;
@ -391,14 +627,38 @@ export function runAssistantAddressLaneResponseRuntime<ResponseType = AssistantM
followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object" followupContextSource?.root_filters && typeof followupContextSource.root_filters === "object"
? (followupContextSource.root_filters as Record<string, unknown>) ? (followupContextSource.root_filters as Record<string, unknown>)
: null; : null;
if (rootIntent || currentFrameKind) { const detectedIntent = input.toNonEmptyString(debug.detected_intent);
const rootFrameWarehouse =
input.toNonEmptyString(rootFilters?.warehouse) ??
input.toNonEmptyString(debugFilters?.warehouse) ??
(detectedIntent === "inventory_on_hand_as_of_date" ? extractSingleWarehouseScopeFromReply(safeAddressReply) : null);
const shouldPersistInventoryRootFrame =
rootIntent || currentFrameKind || detectedIntent === "inventory_on_hand_as_of_date";
if (shouldPersistInventoryRootFrame) {
const rootFiltersForDebug: Record<string, unknown> = {
...(rootFilters ?? {}),
organization: input.toNonEmptyString(rootFilters?.organization) ?? input.toNonEmptyString(debugFilters?.organization),
warehouse: rootFrameWarehouse,
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date) ?? input.toNonEmptyString(debugFilters?.as_of_date),
period_from: input.toNonEmptyString(rootFilters?.period_from) ?? input.toNonEmptyString(debugFilters?.period_from),
period_to: input.toNonEmptyString(rootFilters?.period_to) ?? input.toNonEmptyString(debugFilters?.period_to)
};
for (const key of Object.keys(rootFiltersForDebug)) {
if (!input.toNonEmptyString(rootFiltersForDebug[key])) {
delete rootFiltersForDebug[key];
}
}
debug.address_root_frame_context = { debug.address_root_frame_context = {
root_intent: rootIntent, root_intent: rootIntent ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_on_hand_as_of_date" : null),
current_frame_kind: currentFrameKind, root_filters: rootFiltersForDebug,
organization: input.toNonEmptyString(rootFilters?.organization), root_anchor_type: input.toNonEmptyString(followupContextSource?.root_anchor_type) ?? undefined,
as_of_date: input.toNonEmptyString(rootFilters?.as_of_date), root_anchor_value: input.toNonEmptyString(followupContextSource?.root_anchor_value) ?? undefined,
period_from: input.toNonEmptyString(rootFilters?.period_from), current_frame_kind: currentFrameKind ?? (detectedIntent === "inventory_on_hand_as_of_date" ? "inventory_root" : null),
period_to: input.toNonEmptyString(rootFilters?.period_to) organization: input.toNonEmptyString(rootFiltersForDebug.organization),
warehouse: input.toNonEmptyString(rootFiltersForDebug.warehouse),
as_of_date: input.toNonEmptyString(rootFiltersForDebug.as_of_date),
period_from: input.toNonEmptyString(rootFiltersForDebug.period_from),
period_to: input.toNonEmptyString(rootFiltersForDebug.period_to)
}; };
} }
const debugWithRuntimeContracts = attachAssistantRuntimeContractShadow(debug, { const debugWithRuntimeContracts = attachAssistantRuntimeContractShadow(debug, {

View File

@ -1479,9 +1479,23 @@ export function buildInventoryRootFrameFromAddressDebug(
} }
const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters); const rootFiltersCandidate = toRecordObject(rootFrameContext?.root_filters);
const rootFrameFiltersCandidate = rootFrameContext
? {
organization: toNonEmptyString(rootFrameContext.organization),
warehouse: toNonEmptyString(rootFrameContext.warehouse),
as_of_date: toNonEmptyString(rootFrameContext.as_of_date),
period_from: toNonEmptyString(rootFrameContext.period_from),
period_to: toNonEmptyString(rootFrameContext.period_to)
}
: null;
const rootFrameFilters = rootFrameFiltersCandidate
? Object.fromEntries(
Object.entries(rootFrameFiltersCandidate).filter(([, value]) => Boolean(value))
)
: null;
const filters = { const filters = {
...(rootFiltersCandidate ?? {}), ...(rootFiltersCandidate ?? {}),
...(rootFiltersCandidate ? {} : extractedFilters) ...(rootFiltersCandidate ? {} : rootFrameFilters && Object.keys(rootFrameFilters).length > 0 ? rootFrameFilters : extractedFilters)
}; };
if (!filters.organization) { if (!filters.organization) {
const organization = readAddressDebugOrganization(debug, toNonEmptyString); const organization = readAddressDebugOrganization(debug, toNonEmptyString);

View File

@ -572,6 +572,37 @@ function hasInventoryMarginRankingAddressReply(
); );
} }
function hasExactInventoryPurchaseToSaleChainAddressReply(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): boolean {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
if (!hasEffectivelyFactualAddressReply(input)) {
return false;
}
const source = String(input.currentReplySource ?? input.livingChatSource ?? "").trim().toLowerCase();
if (source !== "address_query_runtime_v1" && source !== "address_exact" && source !== "address_lane") {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
const capabilityId =
toNonEmptyString(input.addressRuntimeMeta?.capability_id) ??
toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id);
if (
detectedIntent !== "inventory_purchase_to_sale_chain" &&
selectedRecipe !== "address_inventory_purchase_to_sale_chain_v1" &&
capabilityId !== "inventory_inventory_purchase_to_sale_chain"
) {
return false;
}
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
const responseType = toNonEmptyString(input.addressRuntimeMeta?.response_type);
return Boolean(hasConfirmedAddressExecution(input) || mcpCallStatus === "matched_non_empty" || responseType === "FACTUAL_LIST");
}
function hasValueFlowActionConflictWithDiscoveryTurnMeaning( function hasValueFlowActionConflictWithDiscoveryTurnMeaning(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput, input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
@ -651,6 +682,16 @@ function hasExactMatchedFactualAddressReply(
} }
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
const askedAction = toNonEmptyString(turnMeaning?.asked_action_family);
if (
detectedIntent === "inventory_supplier_stock_overlap_as_of_date" &&
hasConfirmedAddressExecution(input) &&
(askedDomain === "entity_resolution" || askedAction === "search_business_entity")
) {
return true;
}
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) { if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
return false; return false;
} }
@ -916,6 +957,10 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint); const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint);
const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint); const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint);
const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint); const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint);
const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(
input,
entryPoint
);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning( const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(
@ -930,6 +975,10 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
exactBankOperationsAddressReply && exactBankOperationsAddressReply &&
!semanticConflictWithDiscoveryTurnMeaning && !semanticConflictWithDiscoveryTurnMeaning &&
!valueFlowActionConflictWithDiscoveryTurnMeaning; !valueFlowActionConflictWithDiscoveryTurnMeaning;
const exactInventoryChainProtectsCurrent =
exactInventoryPurchaseToSaleChainAddressReply &&
candidate.candidate_status === "clarification_candidate" &&
valueFlowActionConflictWithDiscoveryTurnMeaning;
if (!entryPoint) { if (!entryPoint) {
pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point");
@ -1009,6 +1058,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
if (inventoryMarginRankingAddressReply) { if (inventoryMarginRankingAddressReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply"); pushReason(reasonCodes, "mcp_discovery_response_policy_keep_inventory_margin_ranking_address_reply");
} }
if (exactInventoryChainProtectsCurrent) {
pushReason(
reasonCodes,
"mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification"
);
}
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
pushReason( pushReason(
reasonCodes, reasonCodes,
@ -1046,6 +1101,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
!exactBankOperationsProtectsCurrent && !exactBankOperationsProtectsCurrent &&
!exactDocumentListAddressReply && !exactDocumentListAddressReply &&
!inventoryMarginRankingAddressReply && !inventoryMarginRankingAddressReply &&
!exactInventoryChainProtectsCurrent &&
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
candidate.eligible_for_future_hot_runtime && candidate.eligible_for_future_hot_runtime &&

View File

@ -70,6 +70,13 @@ function detectSupportedIntent(text, deps) {
reason: "address_intent_resolver_current_turn_signal" reason: "address_intent_resolver_current_turn_signal"
}; };
} }
if (hasInventorySupplierStockOverlapExactSignal(text)) {
return {
intent: "inventory_supplier_stock_overlap_as_of_date",
confidence: "high",
reason: "inventory_supplier_stock_overlap_current_turn_signal"
};
}
if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) { if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e(?:\s+\p{L}+){0,4}\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) {
return { return {
intent: "receivables_confirmed_as_of_date", intent: "receivables_confirmed_as_of_date",
@ -246,6 +253,30 @@ function hasSelectedObjectInventoryExactSignal(text) {
); );
} }
function hasInventorySupplierStockOverlapExactSignal(text) {
const normalized = String(text ?? "");
if (!normalized) {
return false;
}
const hasStockScope =
/(?:\u0441\u043a\u043b\u0430\u0434|\u043e\u0441\u0442\u0430\u0442|\u043b\u0435\u0436\p{L}*|\u043d\u0430\u0445\u043e\u0434\p{L}*|\u0447\u0438\u0441\u043b\p{L}*|warehouse|stock|inventory|on\s+hand)/iu.test(
normalized
);
const hasSupplierCue =
/(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\p{L}*|\u0437\u0430\u043a\u0443\u043f|\u043a\u0443\u043f\p{L}*|\u043f\u0440\u0438\u043e\u0431\u0440\p{L}*|supplier|vendor|purchase|bought|supplied|procurement)/iu.test(
normalized
);
const hasOverlapAction =
/(?:\u0443\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043f\u043e\s+\u043a\u0430\u043a\u043e\u043c\u0443\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u0442\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*[\s\S]{0,80}\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0439\u0442\p{L}*[\s\S]{0,80}\u0442\u043e\u0432\u0430\u0440|\u043a\u0430\u043a\u0438\u0435\s+\u0442\u043e\u0432\u0430\u0440|supplier[\s\S]{0,80}(?:stock|inventory)|(?:stock|inventory)[\s\S]{0,80}supplier)/iu.test(
normalized
);
const hasSupplierIdentityAsk =
/(?:\u043a\u0430\u043a\u043e(?:\u0433\u043e|\u043c\u0443)\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043e\u043f\u0440\u0435\u0434\u0435\u043b\p{L}*\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|which\s+supplier|what\s+supplier)/iu.test(
normalized
);
return hasStockScope && hasSupplierCue && (hasOverlapAction || hasSupplierIdentityAsk);
}
function hasOrganizationLevelEarningsOverviewSignal(text) { function hasOrganizationLevelEarningsOverviewSignal(text) {
const normalized = String(text ?? ""); const normalized = String(text ?? "");
if (!normalized || hasExplicitCounterpartyValueObject(normalized)) { if (!normalized || hasExplicitCounterpartyValueObject(normalized)) {
@ -508,13 +539,17 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText); const counterpartyBidirectionalValueFlow = compactOrganizationCashflowDisplay ? null : detectCounterpartyBidirectionalValueFlowFamily(joinedText);
const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText); const counterpartyTurnover = compactOrganizationCashflowDisplay ? null : detectCounterpartyTurnoverFamily(joinedText);
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText); const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
const inventorySupplierStockOverlapExact =
supportedIntent?.intent === "inventory_supplier_stock_overlap_as_of_date" ||
llmIntent === "inventory_supplier_stock_overlap_as_of_date" ||
hasInventorySupplierStockOverlapExactSignal(joinedText);
const broadBusinessEvaluation = const broadBusinessEvaluation =
compactOrganizationCashflowDisplay compactOrganizationCashflowDisplay
? { family: "broad_business_evaluation" } ? { family: "broad_business_evaluation" }
: selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family : selectedObjectInventoryExact || inventorySupplierStockOverlapExact || counterpartyBidirectionalValueFlow?.family
? null ? null
: detectBroadBusinessEvaluation(joinedText); : detectBroadBusinessEvaluation(joinedText);
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
const explicitIntentCandidate = const explicitIntentCandidate =
broadBusinessEvaluation?.family broadBusinessEvaluation?.family
? null ? null
@ -585,8 +620,10 @@ export function createAssistantTurnMeaningPolicy(deps = {}) {
? "confirmed_tax_period" ? "confirmed_tax_period"
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date" : explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
? "confirmed_snapshot" ? "confirmed_snapshot"
: explicitIntentCandidate === "vat_payable_forecast" : explicitIntentCandidate === "vat_payable_forecast"
? "forecast" ? "forecast"
: explicitIntentCandidate === "inventory_supplier_stock_overlap_as_of_date"
? "supplier_overlap"
: explicitIntentCandidate === "list_documents_by_counterparty" : explicitIntentCandidate === "list_documents_by_counterparty"
? "list_documents" ? "list_documents"
: counterpartyTurnover?.family : counterpartyTurnover?.family

View File

@ -36,6 +36,26 @@ describe("inventory root frame regressions", () => {
expect(result?.filters.warnings).toContain("period_to_from_followup_context"); expect(result?.filters.warnings).toContain("period_to_from_followup_context");
}); });
it("does not treat an explicit warehouse as an item in root inventory snapshot", () => {
const result = runAddressDecomposeStage(
"Какие конкретно номенклатуры формируют остаток по складу Основной склад на дату 2019-03-31",
null,
{
scope_target_kind: "item",
scope_target_text: "Основной склад",
date_scope_kind: "explicit",
self_scope_detected: false,
selected_object_scope_detected: false
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад");
expect(result?.filters.extracted_filters.item).toBeUndefined();
expect(result?.filters.warnings).toContain("item_cleared_as_warehouse_scope_alias_for_inventory_snapshot");
});
it("promotes selected-object provenance slang with 'где мы взяли это' into inventory provenance", () => { it("promotes selected-object provenance slang with 'где мы взяли это' into inventory provenance", () => {
const result = runAddressDecomposeStage( const result = runAddressDecomposeStage(
'По выбранному объекту "Зеркало для инвалидов поворотное травмобезопасное": где мы взяли это говнище?', 'По выбранному объекту "Зеркало для инвалидов поворотное травмобезопасное": где мы взяли это говнище?',
@ -110,6 +130,39 @@ describe("inventory root frame regressions", () => {
expect(result?.intent.intent).not.toBe("bank_operations_by_counterparty"); expect(result?.intent.intent).not.toBe("bank_operations_by_counterparty");
expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)"); expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)");
}); });
it("routes inline selected-object purchase document wording without external followup context", () => {
const result = runAddressDecomposeStage(
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили',
null
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item");
expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
});
it("routes normalized selected-item purchase document wording from LLM predecompose", () => {
const result = runAddressDecomposeStage(
"Уточнить, какие документы поступления связаны с выбранным товаром «Столешница 600*3050*26 дуб ниагара»",
{
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Столешница 600*3050*26 дуб ниагара",
as_of_date: "2019-03-31",
organization: 'ООО "Альтернатива Плюс"'
},
previous_anchor_type: "item",
previous_anchor_value: "Столешница 600*3050*26 дуб ниагара",
root_intent: "inventory_on_hand_as_of_date",
current_frame_kind: "inventory_drilldown"
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item");
expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
});
it("restores stock snapshot intent for selected-object restatement on the previously reviewed date", () => { it("restores stock snapshot intent for selected-object restatement on the previously reviewed date", () => {
const result = runAddressDecomposeStage( const result = runAddressDecomposeStage(
'По выбранному объекту "Столешница 600*3050*26 альмандин": покажи еще раз остатки на дату которую до этого рассматривали', 'По выбранному объекту "Столешница 600*3050*26 альмандин": покажи еще раз остатки на дату которую до этого рассматривали',

View File

@ -3,7 +3,7 @@ import { detectAddressQuestionMode } from "../src/services/addressQueryClassifie
import { resolveAddressIntent } from "../src/services/addressIntentResolver"; import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier"; import { classifyAddressQueryShape } from "../src/services/addressQueryShapeClassifier";
import { extractAddressFilters } from "../src/services/addressFilterExtractor"; import { extractAddressFilters } from "../src/services/addressFilterExtractor";
import { AddressQueryService } from "../src/services/addressQueryService"; import { AddressQueryService, ensureInventorySupplierOverlapLeadLine } from "../src/services/addressQueryService";
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog"; import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage"; import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
import { composeFactualReply } from "../src/services/address_runtime/composeStage"; import { composeFactualReply } from "../src/services/address_runtime/composeStage";
@ -157,6 +157,15 @@ describe("address query shape classifier", () => {
expect(filters.warehouse).toBe("Основной склад"); expect(filters.warehouse).toBe("Основной склад");
}); });
it("does not extract generic goods wording as counterparty for supplier-overlap identity questions", () => {
const filters = extractAddressFilters(
"У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
"inventory_supplier_stock_overlap_as_of_date"
).extracted_filters;
expect(filters.counterparty).toBeUndefined();
expect(filters.warehouse).toBe("Основной склад");
});
it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => { it("cuts inventory item anchor before chain suffix and ignores chain pseudo-warehouse", () => {
const filters = extractAddressFilters( const filters = extractAddressFilters(
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа", "Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
@ -499,6 +508,35 @@ describe("address query shape classifier", () => {
expect(reply.semantics?.result_mode).toBe("confirmed_balance"); expect(reply.semantics?.result_mode).toBe("confirmed_balance");
}); });
it("states directly when only purchase side of the inventory chain is confirmed", () => {
const reply = composeFactualReply(
"inventory_purchase_to_sale_chain",
[
{
period: "2019-12-10T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000111 от 10.12.2019 12:00:01",
account_dt: "41.01",
account_kt: "",
amount: 855000,
analytics: ["Шкаф картотечный 1000*400*2100", "Основной склад", "ООО Альтернатива Плюс", "ЭталонМебель"],
item: "Шкаф картотечный 1000*400*2100",
counterparty: "ЭталонМебель",
organization: "ООО Альтернатива Плюс"
}
],
{
asOfDate: "2020-03-31",
userMessage: "Есть ли цепочка закупка -> продажа по товару Шкаф картотечный 1000*400*2100?"
}
);
expect(reply.text.split("\n")[0]).toContain("закупочная часть цепочки подтверждена");
expect(reply.text.split("\n")[0]).toContain("продажа/выбытие со счета 41.01");
expect(reply.text.split("\n")[0]).not.toContain("частично или разнообразно");
expect(reply.text).toContain("Строк закупки на 41.01: 1");
expect(reply.text).toContain("Строк продажи со счета 41.01: 0");
});
it("states when requested supplier-to-buyer chain parties are not confirmed by item documents", () => { it("states when requested supplier-to-buyer chain parties are not confirmed by item documents", () => {
const reply = composeFactualReply( const reply = composeFactualReply(
"inventory_purchase_to_sale_chain", "inventory_purchase_to_sale_chain",
@ -3648,6 +3686,24 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 9000
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context"); expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
}); });
it("detaches analysis-context snapshot date for inventory purchase-to-sale chain execution", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"Через какие документы прошел путь товара Шкаф картотечный 1000*400*2100: закупка -> склад -> продажа",
{
analysisDateHint: "2020-03-31",
activeOrganization: 'ООО "Альтернатива Плюс"'
}
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("inventory_purchase_to_sale_chain");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
expect(result?.reply_text).toContain("Строк продажи со счета 41.01: 1");
});
it("returns soft out-of-scope reply without technical jargon for unsupported supplier-control wording", async () => { it("returns soft out-of-scope reply without technical jargon for unsupported supplier-control wording", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle( const result = await service.tryHandle(
@ -4511,6 +4567,33 @@ it("auto-broadens out-of-window period after contracts pivot and keeps requested
}); });
describe("address decompose stage follow-up carryover", () => { describe("address decompose stage follow-up carryover", () => {
it("keeps old-purchase residue follow-up in aging route with inherited stock date", () => {
const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", {
previous_intent: "inventory_on_hand_as_of_date",
target_intent: "inventory_on_hand_as_of_date",
root_intent: "inventory_on_hand_as_of_date",
current_frame_kind: "inventory_root",
previous_filters: {
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"'
},
root_filters: {
as_of_date: "2021-09-30",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"'
}
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_aging_by_purchase_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2021-09-30");
expect(result?.filters.extracted_filters.warehouse).toBe("Основной склад");
expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"');
});
it("promotes selected-object supplier slang follow-up into inventory provenance with inherited date context", () => { it("promotes selected-object supplier slang follow-up into inventory provenance with inherited date context", () => {
const result = runAddressDecomposeStage('По выбранному объекту "Столешница 600*3050*26 дуб ниагара": кто это поставил нам', { const result = runAddressDecomposeStage('По выбранному объекту "Столешница 600*3050*26 дуб ниагара": кто это поставил нам', {
previous_intent: "inventory_on_hand_as_of_date", previous_intent: "inventory_on_hand_as_of_date",
@ -4736,6 +4819,39 @@ describe("address decompose stage follow-up carryover", () => {
expect(result.warnings).toContain("item_llm_semantics_ignored"); expect(result.warnings).toContain("item_llm_semantics_ignored");
}); });
it("clears generic goods nouns from llm semantic counterparty hints", () => {
const result = applyAddressLlmSemanticHintsToExtraction(
{
extracted_filters: {
sort: "period_desc",
warehouse: "Основной склад"
},
missing_required_filters: [],
warnings: [],
semantic_frame: {
scope_kind: "explicit_anchor",
anchor_kind: "warehouse",
anchor_value: "Основной склад",
date_scope_kind: "implicit_current",
date_basis_hint: "implicit_current_snapshot",
self_scope_detected: false,
selected_object_scope_detected: false
}
},
{
scope_target_kind: "counterparty",
scope_target_text: "товаров,",
date_scope_kind: "implicit_current",
self_scope_detected: false,
selected_object_scope_detected: false
}
);
expect(result.extracted_filters.counterparty).toBeUndefined();
expect(result.extracted_filters.warehouse).toBe("Основной склад");
expect(result.warnings).toContain("counterparty_cleared_low_quality_llm_semantics");
});
it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => { it("keeps slang all-customers-all-time wording in address lane via resolved intent fallback", () => {
const result = runAddressDecomposeStage("выведи всех заков за все время", null); const result = runAddressDecomposeStage("выведи всех заков за все время", null);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
@ -5889,7 +6005,7 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки("); expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки(");
expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары"); expect(plan.query).toContain("Документ.ПоступлениеТоваровУслуг.Товары");
expect(plan.query).toContain("Остатки.КоличествоРазвернутыйОстатокДт > 0"); expect(plan.query).toContain("Остатки.КоличествоРазвернутыйОстатокДт > 0");
expect(plan.query).toContain("Товары.Номенклатура В"); expect(plan.query).not.toContain("Товары.Номенклатура В");
expect(plan.query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2026, 4, 18, 23, 59, 59)"); expect(plan.query).toContain("Товары.Ссылка.Дата <= ДАТАВРЕМЯ(2026, 4, 18, 23, 59, 59)");
}); });
@ -6045,6 +6161,8 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
); );
expect(reply.responseType).toBe("FACTUAL_SUMMARY"); expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text.split("\n")[0]).toContain("выявлен поставщик закупочного следа");
expect(reply.text.split("\n")[0]).toContain("Торговый дом");
expect(reply.text).toContain("Что проверили:"); expect(reply.text).toContain("Что проверили:");
expect(reply.text).toContain("Опорные документы:"); expect(reply.text).toContain("Опорные документы:");
expect(reply.text).not.toContain("exact-контур"); expect(reply.text).not.toContain("exact-контур");
@ -6070,17 +6188,133 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
], ],
{ {
asOfDate: "2021-09-30", asOfDate: "2021-09-30",
userMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику", userMessage: "Покажи закупочный след текущего складского остатка",
rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику",
useRubCurrency: true useRubCurrency: true
} }
); );
expect(reply.responseType).toBe("FACTUAL_SUMMARY"); expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text.split("\n")[0]).toContain("без явно выделенного поставщика"); expect(reply.text.split("\n")[0]).toContain("без надежной привязки к поставщику");
expect(reply.text).toContain("Позиции без явно выделенного поставщика:"); expect(reply.text.split("\n")[0]).toContain("первые примеры ниже");
expect(reply.text).toContain("Позиции без надежной привязки к поставщику:");
expect(reply.text).toContain("Товар без поставщика");
expect(reply.text).toContain("Опорные документы:");
expect(reply.text).not.toContain("Уточните"); expect(reply.text).not.toContain("Уточните");
}); });
it("derives unresolved supplier-link month filters from raw user wording before organization clarification", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("По какому поставщику проходит текущий товарный остаток", {
analysisDateHint: "2021-09-30",
knownOrganizations: ['ООО "Альтернатива Плюс"', 'ООО "Актив"'],
rawUserMessage: "Какие товары сейчас висят в остатке без понятной привязки к поставщику"
});
expect(result?.debug.detected_intent).toBe("inventory_supplier_stock_overlap_as_of_date");
expect(result?.debug.extracted_filters).toMatchObject({
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
});
});
it("keeps broad supplier-overlap answers direct-first when supplier is not separated", () => {
const reply = composeFactualReply(
"inventory_supplier_stock_overlap_as_of_date",
[
{
period: "2021-09-30T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000999 от 30.09.2021 0:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 1200,
analytics: ["Товар без поставщика", "Основной склад"],
item: "Товар без поставщика",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"'
}
],
{
asOfDate: "2021-09-30",
useRubCurrency: true
}
);
const firstLine = reply.text.split("\n")[0] ?? "";
expect(firstLine).toContain("По складскому остатку Основной склад");
expect(firstLine).toContain("однозначный поставщик текущего остатка не подтвержден");
expect(firstLine).toContain("1");
expect(firstLine).not.toContain("Часть закупочных операций");
expect(reply.text).not.toContain("Часть закупочных операций");
expect(reply.text).toContain("закупочных операций без явно выделенного поставщика");
});
it("repairs supplier-overlap lead line when evidence rows display counterparties", () => {
const staleText = [
"По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.",
"Что проверили:",
"- Дата среза: 30.09.2021.",
"Опорные документы:",
"1. Поступление товаров и услуг 00000000001 | контрагент: А",
"2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ",
"3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова"
].join("\n");
const repaired = ensureInventorySupplierOverlapLeadLine("inventory_supplier_stock_overlap_as_of_date", staleText);
const lines = repaired.split("\n");
expect(lines[0]).toContain("найдено несколько поставщиков закупочного следа");
expect(lines[0]).toContain("А");
expect(lines[0]).toContain("ВИЗАНТИЯ");
expect(lines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена");
});
it("answers supplier-scoped stock questions with item list first", () => {
const reply = composeFactualReply(
"inventory_supplier_stock_overlap_as_of_date",
[
{
period: "2019-02-12T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 3075,
analytics: ["Столешница 600*3050*26 альмандин", "Основной склад", "Торговый дом \"Союз МСК\""],
item: "Столешница 600*3050*26 альмандин",
warehouse: "Основной склад",
counterparty: "Торговый дом \"Союз МСК\"",
organization: 'ООО "Альтернатива Плюс"'
},
{
period: "2019-02-12T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 3724.17,
analytics: ["Столешница 600*3050*26 дуб ниагара", "Основной склад", "Торговый дом \"Союз МСК\""],
item: "Столешница 600*3050*26 дуб ниагара",
warehouse: "Основной склад",
counterparty: "Торговый дом \"Союз МСК\"",
organization: 'ООО "Альтернатива Плюс"'
}
],
{
asOfDate: "2021-09-30",
counterpartyHint: "Торговый дом \\Союз",
userMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад",
useRubCurrency: true
}
);
const firstLine = reply.text.split("\n")[0] ?? "";
expect(firstLine).toContain("подтверждены позиции");
expect(firstLine).toContain("Столешница 600*3050*26 альмандин");
expect(firstLine).toContain("Столешница 600*3050*26 дуб ниагара");
expect(firstLine).not.toContain("выявлен поставщик закупочного следа");
expect(reply.text).toContain("Позиции:");
});
it("routes inventory provenance questions to a dedicated intent", () => { it("routes inventory provenance questions to a dedicated intent", () => {
const result = resolveAddressIntent("От какого поставщика куплен товар Шкаф картоотечный?"); const result = resolveAddressIntent("От какого поставщика куплен товар Шкаф картоотечный?");
expect(result.intent).toBe("inventory_purchase_provenance_for_item"); expect(result.intent).toBe("inventory_purchase_provenance_for_item");
@ -6145,6 +6379,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => {
expect(result.intent).toBe("inventory_aging_by_purchase_date"); expect(result.intent).toBe("inventory_aging_by_purchase_date");
}); });
it("routes old-purchase residue follow-up wording to aging intent", () => {
const result = resolveAddressIntent("Какие остатки по товарам на эту дату относятся к старым закупкам");
expect(result.intent).toBe("inventory_aging_by_purchase_date");
});
it("routes old stock wording with residue anchor to aging intent", () => { it("routes old stock wording with residue anchor to aging intent", () => {
const result = resolveAddressIntent("Это остаток по очень давно купленному товару?"); const result = resolveAddressIntent("Это остаток по очень давно купленному товару?");
expect(result.intent).toBe("inventory_aging_by_purchase_date"); expect(result.intent).toBe("inventory_aging_by_purchase_date");

View File

@ -526,6 +526,73 @@ describe("address reply builders regressions", () => {
expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список"); expect(result?.text).toContain("Следующий шаг: могу раскрыть полный список");
}); });
it("surfaces account and warehouse scope in on-hand snapshot top line", () => {
const result = composeInventoryReply(
"inventory_on_hand_as_of_date",
[
{
amount: 614098.33,
quantity: 13,
item: "Моноблок леново",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
period: "2019-03-31",
registrator: "Остатки"
} as any
],
{
userMessage: "Какие товары числятся на 41 счете на дату 2019-03-31",
accountHint: "41",
warehouseHint: "Основной склад",
asOfDate: "2019-03-31"
},
{
resolvePayablesAsOfDate: () => "2019-03-31",
buildInventoryOnHandAggregate: () => [
{
item: "Моноблок леново",
warehouse: "Основной склад",
organization: 'ООО "Альтернатива Плюс"',
quantity: 13,
amount: 614098.33,
operations: 1,
firstPeriod: "2019-03-31",
lastPeriod: "2019-03-31",
sourceRefs: []
}
],
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
formatDateRu: (value: string) => value,
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
formatMoneyRub: (value: number) => `${value}`,
isInventoryPurchaseMovement: () => false,
summarizeInventoryTraceRows: () => ({
item: null,
warehouses: [],
organizations: [],
counterparties: [],
documents: [],
firstPeriod: null,
lastPeriod: null,
totalAmount: 0
}),
formatInventoryTraceRows: () => [],
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: () => "",
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: () => false
}
);
const firstLine = result?.text.split("\n")[0] ?? "";
expect(firstLine).toContain("по счету 41");
expect(firstLine).toContain("по складу Основной склад");
expect(result?.text).toContain("Срез по счету 41.");
expect(result?.text).toContain("Срез по складу Основной склад.");
});
it("answers cost-base evidence follow-up directly when margin ranking has sales without confirmed cost", () => { it("answers cost-base evidence follow-up directly when margin ranking has sales without confirmed cost", () => {
const result = composeInventoryReply( const result = composeInventoryReply(
"inventory_margin_ranking_for_nomenclature", "inventory_margin_ranking_for_nomenclature",
@ -753,4 +820,87 @@ describe("address reply builders regressions", () => {
expect(result?.text).not.toContain("сумма:"); expect(result?.text).not.toContain("сумма:");
expect(result?.text).not.toContain("8.520,00 ₽"); expect(result?.text).not.toContain("8.520,00 ₽");
}); });
it("keeps supplier-overlap stock answer direct-first and compact on broad warehouse traces", () => {
const rows = [
{
period: "2021-03-09",
registrator: "Поступление товаров и услуг 00000000001",
item: "Рабочая станция",
warehouse: "Основной склад",
counterparty: "А"
},
{
period: "2020-11-20",
registrator: "Поступление товаров и услуг 00000000030",
item: "Зеркало",
warehouse: "Основной склад",
counterparty: "ВИЗАНТИЯ"
},
{
period: "2020-07-13",
registrator: "Поступление товаров и услуг 00000000029",
item: "Стул",
warehouse: "Основной склад",
counterparty: "ИП Тучкова"
},
{
period: "2019-01-10",
registrator: "Поступление товаров и услуг 00000000077",
item: "Позиция без поставщика",
warehouse: "Основной склад"
}
] as any[];
const result = composeInventoryReply(
"inventory_supplier_stock_overlap_as_of_date",
rows,
{
userMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад",
asOfDate: "2021-09-30"
},
{
resolvePayablesAsOfDate: () => "2021-09-30",
buildInventoryOnHandAggregate: () => [],
uniqueStrings: (values: string[]) => Array.from(new Set(values)),
formatDateRu: (value: string) => value,
formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits),
formatMoneyRub: (value: number) => `${value}`,
isInventoryPurchaseMovement: () => true,
summarizeInventoryTraceRows: (inputRows: any[]) => ({
item: inputRows[0]?.item ?? null,
warehouses: ["Основной склад"],
organizations: [],
counterparties: [],
documents: inputRows.map((row) => String(row.registrator ?? "")),
firstPeriod: "2019-01-10",
lastPeriod: "2021-03-09",
totalAmount: 0
}),
formatInventoryTraceRows: (inputRows: any[], limit = 10) =>
inputRows.slice(0, limit).map((row, index) => {
const parts = [`${index + 1}. ${row.registrator}`];
if (row.counterparty) {
parts.push(`контрагент: ${row.counterparty}`);
}
return parts.join(" | ");
}),
hasInventoryPurchaseDateActionFocus: () => false,
inventoryTraceDateLabel: (value: string | null) => String(value ?? ""),
extractInventoryCounterpartyCandidates: () => [],
buildInventoryAgingByItemAggregate: () => [],
formatInventoryAgingRows: () => [],
isInventorySaleMovement: () => false
}
);
const text = result?.text ?? "";
const firstLine = text.split("\n")[0] ?? "";
expect(firstLine).toContain("найдено несколько поставщиков закупочного следа");
expect(firstLine).toContain("А");
expect(firstLine).toContain("ВИЗАНТИЯ");
expect(text.indexOf(firstLine)).toBeLessThan(text.indexOf("Что проверили:"));
expect(text).toContain("однозначная атрибуция части текущего остатка не подтверждена: 1 закупочных операций без явно выделенного поставщика.");
expect(text).not.toContain("Часть закупочных операций");
expect(text.match(/^\d+\./gmu)?.length ?? 0).toBeLessThanOrEqual(3);
});
}); });

View File

@ -100,6 +100,126 @@ describe("assistant address lane response runtime adapter", () => {
); );
}); });
it("repairs inventory supplier-overlap lead line before finalizing the user reply", () => {
const finalizeAddressTurn = vi.fn(() => ({
response: {
ok: true
}
}));
const runtime = runAssistantAddressLaneResponseRuntime({
sessionId: "asst-supplier-overlap",
userMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
addressLane: {
handled: true,
reply_text: [
"По складскому остатку Основной склад на 30.09.2021 однозначная атрибуция части текущего остатка не подтверждена: 192 закупочных операций без явно выделенного поставщика.",
"Что проверили:",
"- Дата среза: 30.09.2021.",
"Опорные документы:",
"1. Поступление товаров и услуг 00000000001 | контрагент: А",
"2. Поступление товаров и услуг 00000000030 | контрагент: ВИЗАНТИЯ",
"3. Поступление товаров и услуг 00000000031 | контрагент: ИП Тучкова"
].join("\n"),
reply_type: "factual",
debug: {
extracted_filters: {
warehouse: "Основной склад",
as_of_date: "2021-09-30"
}
}
},
knownOrganizations: [],
activeOrganization: null,
sanitizeOutgoingAssistantText: (text) =>
String(text ?? "")
.split(/\r?\n/u)
.filter((line) => !line.includes("найдено несколько поставщиков закупочного следа"))
.join("\n")
.trim(),
buildAddressDebugPayload: (addressDebug) => ({
...(addressDebug as Record<string, unknown>),
detected_intent: "inventory_supplier_stock_overlap_as_of_date"
}),
buildAddressFollowupOffer: () => null,
mergeKnownOrganizations: (items) => Array.from(new Set(items)),
toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null),
appendItem: () => {},
getSession: () => ({ session_id: "asst-supplier-overlap", updated_at: "", items: [], investigation_state: null } as any),
persistSession: () => {},
cloneConversation: (items) => items,
logEvent: () => {},
messageIdFactory: () => "msg-supplier-overlap",
finalizeAddressTurn
});
const assistantReply = (finalizeAddressTurn.mock.calls[0]?.[0] as any)?.assistantReply ?? "";
const nonEmptyLines = assistantReply.split("\n").filter((line: string) => line.trim().length > 0);
expect(nonEmptyLines[0]).toContain("найдено несколько поставщиков закупочного следа");
expect(nonEmptyLines[0]).toContain("А");
expect(nonEmptyLines[1]).toContain("однозначная атрибуция части текущего остатка не подтверждена");
expect(runtime.debug.inventory_supplier_overlap_surface_repair_v1).toEqual(
expect.objectContaining({
applied: true,
lead_preserved_after_sanitize: true,
counterparties: ["А", "ВИЗАНТИЯ", "ИП Тучкова"]
})
);
});
it("persists inventory root frame filters with a single warehouse derived from the factual reply", () => {
const runtime = runAssistantAddressLaneResponseRuntime({
sessionId: "asst-inventory-root",
userMessage: "какие остатки на складе на сентябрь 2021",
effectiveAddressUserMessage: "какие остатки на складе на сентябрь 2021",
addressLane: {
handled: true,
reply_text:
'На 30.09.2021 по складу Основной склад подтверждено 11 позиций.\n1. Диван | склад: Основной склад | организация: ООО "Альтернатива Плюс"',
reply_type: "factual",
debug: {
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
}
}
},
knownOrganizations: ['ООО "Альтернатива Плюс"'],
activeOrganization: 'ООО "Альтернатива Плюс"',
sanitizeOutgoingAssistantText: (text) => String(text ?? "").trim(),
buildAddressDebugPayload: (addressDebug) => ({ ...(addressDebug as Record<string, unknown>) }),
buildAddressFollowupOffer: () => null,
mergeKnownOrganizations: (items) => Array.from(new Set(items)),
toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null),
appendItem: () => {},
getSession: () => ({ session_id: "asst-inventory-root", updated_at: "", items: [], investigation_state: null } as any),
persistSession: () => {},
cloneConversation: (items) => items,
logEvent: () => {},
messageIdFactory: () => "msg-inventory-root",
finalizeAddressTurn: () => ({ response: { ok: true } as any })
});
expect(runtime.debug.address_root_frame_context).toEqual(
expect.objectContaining({
root_intent: "inventory_on_hand_as_of_date",
current_frame_kind: "inventory_root",
warehouse: "Основной склад",
root_filters: expect.objectContaining({
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
})
})
);
});
it("attaches MCP discovery summary from predecompose runtime meta without changing the reply", () => { it("attaches MCP discovery summary from predecompose runtime meta without changing the reply", () => {
const runtime = runAssistantAddressLaneResponseRuntime({ const runtime = runAssistantAddressLaneResponseRuntime({
sessionId: "asst-mcp", sessionId: "asst-mcp",

View File

@ -5,6 +5,7 @@ import {
applyReferencedEntityCarryover, applyReferencedEntityCarryover,
applySelectedItemCarryover, applySelectedItemCarryover,
applyTemporalCarryoverFilters, applyTemporalCarryoverFilters,
buildInventoryRootFrameFromAddressDebug,
buildRootScopedCarryoverFilters, buildRootScopedCarryoverFilters,
hydrateInventoryRootFrameState, hydrateInventoryRootFrameState,
readAddressDebugCounterparty, readAddressDebugCounterparty,
@ -479,6 +480,35 @@ describe("assistantContinuityPolicy organization authority", () => {
}); });
}); });
it("builds inventory root frame from compact root-frame context without leaking drilldown item scope", () => {
const frame = buildInventoryRootFrameFromAddressDebug({
detected_intent: "inventory_purchase_provenance_for_item",
extracted_filters: {
item: "Четки Пост (84*117)",
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2021-09-30"
},
address_root_frame_context: {
root_intent: "inventory_on_hand_as_of_date",
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30",
current_frame_kind: "inventory_drilldown"
}
});
expect(frame?.filters).toEqual({
organization: 'ООО "Альтернатива Плюс"',
warehouse: "Основной склад",
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30"
});
expect(frame?.filters).not.toHaveProperty("item");
});
it("does not carry MCP discovery counterparty from a stale replay-forbidden turn", () => { it("does not carry MCP discovery counterparty from a stale replay-forbidden turn", () => {
const filters = resolveAddressDebugCarryoverFilters({ const filters = resolveAddressDebugCarryoverFilters({
detected_intent: "payables_confirmed_as_of_date", detected_intent: "payables_confirmed_as_of_date",

View File

@ -439,6 +439,108 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
}); });
it("keeps exact inventory purchase-to-sale replies over stale value-flow clarification candidates", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply:
"По товару Шкаф картотечный 1000*400*2100 закупочная часть цепочки подтверждена, но продажа/выбытие со счета 41.01 в доступных данных не найдены.",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "factual",
addressRuntimeMeta: {
detected_intent: "inventory_purchase_to_sale_chain",
selected_recipe: "address_inventory_purchase_to_sale_chain_v1",
mcp_call_status: "matched_non_empty",
truth_mode: "confirmed",
capability_binding_status: "bound",
capability_binding_violations: [],
answer_shape_contract: {
reply_type: "factual",
capability_contract_id: "inventory_inventory_purchase_to_sale_chain"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
bridge: {
bridge_status: "needs_clarification",
user_facing_response_allowed: true,
business_fact_answer_allowed: false,
requires_user_clarification: true,
answer_draft: {
answer_mode: "clarification_required",
headline:
"Коротко: Могу посчитать общий денежный поток в проверяемом окне, но для проверяемого поиска в 1С нужен проверяемый период.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: "Уточните период, и я продолжу поиск по денежному потоку в 1С."
}
},
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "counterparty_value",
asked_action_family: "payout",
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
},
data_need_graph: {
business_fact_family: "value_flow",
action_family: "payout",
clarification_gaps: ["period"]
}
}
})
}
});
expect(result.applied).toBe(false);
expect(result.decision).toBe("keep_current_reply");
expect(result.reply_text).toContain("закупочная часть цепочки подтверждена");
expect(result.reply_text).not.toContain("денежный поток");
expect(result.reason_codes).toContain(
"mcp_discovery_response_policy_keep_exact_inventory_chain_reply_over_value_flow_clarification"
);
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_candidate_applied");
});
it("keeps confirmed supplier-overlap address replies over entity-resolution discovery candidates", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply:
'По складскому остатку Основной склад на 30.09.2021 выявлен поставщик закупочного следа: Торговый дом "Союз МСК".',
currentReplySource: "address_query_runtime_v1",
currentReplyType: "factual",
addressRuntimeMeta: {
detected_intent: "inventory_supplier_stock_overlap_as_of_date",
selected_recipe: "address_inventory_supplier_stock_overlap_as_of_date_v1",
mcp_call_status: "matched_non_empty",
truth_mode: "confirmed",
capability_binding_status: "bound",
capability_binding_violations: [],
answer_shape_contract: {
reply_type: "factual",
capability_contract_id: "inventory_inventory_supplier_stock_overlap_as_of_date"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "entity_resolution",
asked_action_family: "search_business_entity",
unsupported_but_understood_family: "entity_resolution",
explicit_entity_candidates: ['Торговый дом "Союз"']
}
}
})
}
});
expect(result.applied).toBe(false);
expect(result.decision).toBe("keep_current_reply");
expect(result.reply_text).toContain("выявлен поставщик закупочного следа");
expect(result.reply_text).not.toContain("Confirmed fact");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
});
it("lets metadata discovery override a stale exact address answer outside protected inventory chains", () => { it("lets metadata discovery override a stale exact address answer outside protected inventory chains", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({ const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "ИП Калинин Н.М. | сумма: 216600", currentReply: "ИП Калинин Н.М. | сумма: 216600",

View File

@ -336,6 +336,38 @@ describe("assistantTurnMeaningPolicy", () => {
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal"); expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
}); });
it.each([
{
rawUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад",
effectiveAddressUserMessage: "У какого поставщика были куплены товары, которые сейчас лежат на складе Основной склад"
},
{
rawUserMessage: "По какому поставщику проходит текущий товарный остаток на складе Основной склад",
effectiveAddressUserMessage:
"Определить поставщика, с которого получен текущий товарный остаток на складе Основной склад"
},
{
rawUserMessage: "Какие товары от поставщика Торговый дом \\Союз сейчас еще лежат на складе Основной склад",
effectiveAddressUserMessage:
"Найти товары, которые находятся на складе Основной склад и были поставлены поставщиком Торговый дом \\Союз"
}
])("keeps supplier stock-overlap wording in the exact inventory lane", ({ rawUserMessage, effectiveAddressUserMessage }) => {
const policy = buildPolicy();
const meaning = policy.resolveAssistantTurnMeaning({
rawUserMessage,
effectiveAddressUserMessage
});
expect(meaning.explicit_intent_candidate).toBe("inventory_supplier_stock_overlap_as_of_date");
expect(meaning.asked_domain_family).toBe("inventory");
expect(meaning.asked_action_family).toBe("supplier_overlap");
expect(meaning.unsupported_but_understood_family).toBeNull();
expect(meaning.stale_replay_forbidden).toBe(false);
expect(meaning.reason_codes).toContain("address_intent_resolver_current_turn_signal");
expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal");
});
it("treats paired receivables/payables position as business overview instead of one debt side", () => { it("treats paired receivables/payables position as business overview instead of one debt side", () => {
const policy = buildPolicy(); const policy = buildPolicy();