ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить каскад складских follow-up: удержать дату, поднять прямой ответ вверх и усилить analyst-loop

This commit is contained in:
2026-04-14 15:11:46 +03:00
parent d41819eabd
commit 27bd4f18fb
16 changed files with 486 additions and 46 deletions
@@ -1077,6 +1077,9 @@ function extractAddressFilters(userMessage, intent) {
const filters = {
sort: "period_desc"
};
if (intent === "inventory_aging_by_purchase_date") {
filters.sort = "period_asc";
}
if (!isManagementProfileIntent && !usesRecipeDefaultLimit(intent)) {
if (intent !== "open_contracts_confirmed_as_of_date") {
filters.limit = 20;
+16 -1
View File
@@ -1550,6 +1550,21 @@ function composeAutoBroadenedPeriodPrefix(requested, observed) {
}
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
}
function injectNoticeAfterLeadLine(text, notice) {
const normalizedText = typeof text === "string" ? text : "";
const normalizedNotice = typeof notice === "string" ? notice.trim() : "";
if (!normalizedText.trim()) {
return normalizedNotice;
}
if (!normalizedNotice) {
return normalizedText;
}
const lines = normalizedText.split("\n");
if (lines.length <= 1) {
return `${lines[0]}\n${normalizedNotice}`;
}
return [lines[0], normalizedNotice, ...lines.slice(1)].join("\n");
}
function runtimeReadinessForLimitedCategory(category) {
if (category === "empty_match" || category === "missing_anchor") {
return "LIVE_QUERYABLE_WITH_LIMITS";
@@ -2866,7 +2881,7 @@ class AddressQueryService {
const broadenedReasons = [...baseReasons, "period_window_auto_broadened_to_available_data"];
return {
handled: true,
reply_text: `${broadenedPrefix}\n${broadenedFactual.text}`,
reply_text: injectNoticeAfterLeadLine(broadenedFactual.text, broadenedPrefix),
reply_type: (0, composeStage_1.inferReplyType)(broadenedFactual.responseType),
response_type: broadenedFactual.responseType,
debug: {
@@ -813,6 +813,111 @@ function formatInventoryTraceRows(rows, limit = 10) {
return parts.join(" | ");
});
}
function buildInventoryAgingByItemAggregate(rows, asOfDate) {
const byItem = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const item = extractInventoryItemName(row);
if (!item) {
continue;
}
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const warehouse = extractInventoryWarehouseName(row);
const organization = extractInventoryOrganizationName(row);
const key = [normalizeEntityToken(item), normalizeEntityToken(warehouse), normalizeEntityToken(organization)].join("|");
const registrator = String(row.registrator ?? "").trim();
const current = byItem.get(key);
if (!current) {
byItem.set(key, {
item,
warehouse,
organization,
firstPurchasePeriod: row.period,
lastPurchasePeriod: row.period,
operations: 1,
documents: new Set(registrator && registrator !== "(без названия)" ? [registrator] : []),
counterparties: new Set(extractInventoryCounterpartyCandidates(row))
});
continue;
}
current.operations += 1;
if ((row.period ?? "") < (current.firstPurchasePeriod ?? "")) {
current.firstPurchasePeriod = row.period;
}
if ((row.period ?? "") > (current.lastPurchasePeriod ?? "")) {
current.lastPurchasePeriod = row.period;
}
if (registrator && registrator !== "(без названия)") {
current.documents.add(registrator);
}
for (const counterparty of extractInventoryCounterpartyCandidates(row)) {
current.counterparties.add(counterparty);
}
}
return Array.from(byItem.values())
.map((item) => {
const firstTimestamp = toUtcDayTimestamp(item.firstPurchasePeriod);
const ageDays = asOfTimestamp !== null &&
firstTimestamp !== null &&
Number.isFinite(asOfTimestamp) &&
Number.isFinite(firstTimestamp) &&
firstTimestamp <= asOfTimestamp
? Math.floor((asOfTimestamp - firstTimestamp) / 86_400_000)
: null;
return {
item: item.item,
warehouse: item.warehouse,
organization: item.organization,
firstPurchasePeriod: item.firstPurchasePeriod,
lastPurchasePeriod: item.lastPurchasePeriod,
operations: item.operations,
documentCount: item.documents.size,
counterparties: Array.from(item.counterparties).sort((left, right) => left.localeCompare(right, "ru")),
ageDays
};
})
.sort((left, right) => {
const leftAge = left.ageDays ?? Number.NEGATIVE_INFINITY;
const rightAge = right.ageDays ?? Number.NEGATIVE_INFINITY;
if (rightAge !== leftAge) {
return rightAge - leftAge;
}
if ((left.firstPurchasePeriod ?? "") !== (right.firstPurchasePeriod ?? "")) {
return String(left.firstPurchasePeriod ?? "").localeCompare(String(right.firstPurchasePeriod ?? ""), "ru");
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.item.localeCompare(right.item, "ru");
});
}
function formatInventoryAgingRows(items, asOfDate, limit = 10) {
return items.slice(0, limit).map((item, index) => {
const parts = [
`${index + 1}. ${item.item}`,
`первая закупка: ${inventoryTraceDateLabel(item.firstPurchasePeriod)}`,
`последняя закупка: ${inventoryTraceDateLabel(item.lastPurchasePeriod)}`,
`документов: ${formatNumberWithDots(item.documentCount)}`,
`операций: ${formatNumberWithDots(item.operations)}`
];
if (item.ageDays !== null) {
parts.push(`возраст следа на ${formatDateRu(asOfDate)}: ${formatNumberWithDots(item.ageDays)} дн.`);
}
if (item.warehouse) {
parts.push(`склад: ${item.warehouse}`);
}
if (item.organization) {
parts.push(`организация: ${item.organization}`);
}
if (item.counterparties.length > 0) {
parts.push(`поставщики: ${item.counterparties.slice(0, 3).join("; ")}`);
}
return parts.join(" | ");
});
}
function liabilityCategoryLabel(category) {
if (category === "supplier_or_contractor") {
return "поставщики/подрядчики";
@@ -3039,7 +3144,13 @@ function composeFactualReply(intent, rows, options = {}) {
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const itemLabel = summary.item ?? "товар не определен";
const directAnswerLine = summary.counterparties.length === 1
? `Товар ${itemLabel} по доступным закупочным движениям связан с поставщиком: ${summary.counterparties[0]}.`
: summary.counterparties.length > 1
? `По доступным закупочным движениям по товару ${itemLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`
: `По товару ${itemLabel} найден закупочный след, но поставщик не материализован отдельным полем в текущем exact-контуре.`;
const lines = [
directAnswerLine,
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@@ -3122,44 +3233,52 @@ function composeFactualReply(intent, rows, options = {}) {
const asOfDate = resolvePayablesAsOfDate(options);
const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row));
const summary = summarizeInventoryTraceRows(purchaseRows);
const firstPeriodTime = summary.firstPeriod ? Date.parse(summary.firstPeriod) : Number.NaN;
const asOfTime = Date.parse(`${asOfDate}T23:59:59.000Z`);
const ageDays = Number.isFinite(firstPeriodTime) && Number.isFinite(asOfTime) && firstPeriodTime <= asOfTime
? Math.floor((asOfTime - firstPeriodTime) / 86_400_000)
: null;
const itemLabel = summary.item ?? "выбранному складскому остатку";
const agingItems = buildInventoryAgingByItemAggregate(purchaseRows, asOfDate);
const oldestPurchaseDate = agingItems[0]?.firstPurchasePeriod ?? summary.firstPeriod;
const oldestPurchaseAgeDays = agingItems[0]?.ageDays ?? null;
const oldestAnswerPreview = agingItems
.slice(0, 3)
.map((item) => `${item.item} (${inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; ");
const directAnswerLine = agingItems.length > 0
? `К старым закупкам на ${formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
: `По доступному закупочному следу на ${formatDateRu(asOfDate)} позиции старых закупок не материализованы.`;
const lines = [
`Собран exact-срез возраста закупочного следа по ${itemLabel} до ${formatDateRu(asOfDate)}.`,
directAnswerLine,
`Собран exact-срез старых закупок для складского остатка на ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
"- Контур: показаны подтвержденные закупочные движения на 41.01 и их временной разброс.",
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый диапазон закупок.",
"- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.",
"- Порядок: позиции отсортированы от самой старой первой закупки к более новым.",
"- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.",
"",
"Блок 2. Сводка",
`- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`,
`- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.`
`- Дата среза: ${formatDateRu(asOfDate)}.`,
`- Самая ранняя первая закупка среди позиций: ${inventoryTraceDateLabel(oldestPurchaseDate)}.`,
`- Самая поздняя найденная закупка в наблюдаемом следе: ${inventoryTraceDateLabel(summary.lastPeriod)}.`,
`- Позиции в aging-срезе: ${formatNumberWithDots(agingItems.length)}.`,
`- Закупочных документов в наблюдаемом следе: ${formatNumberWithDots(summary.documents.length)}.`,
`- Закупочных операций в наблюдаемом следе: ${formatNumberWithDots(purchaseRows.length)}.`
];
if (ageDays !== null) {
lines.push(`- Между самой ранней найденной закупкой и датой среза прошло ${formatNumberWithDots(ageDays)} дн.`);
if (oldestPurchaseAgeDays !== null) {
lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${formatNumberWithDots(oldestPurchaseAgeDays)} дн.`);
}
if (summary.counterparties.length > 0) {
lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`);
}
if (purchaseRows.length > 0) {
lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 8));
if (agingItems.length > 0) {
lines.push("", "Блок 3. Позиции от самых старых закупок", ...formatInventoryAgingRows(agingItems, asOfDate, 12));
}
else {
lines.push("", "Блок 3. Опорные документы", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза.");
}
return {
responseType: "FACTUAL_SUMMARY",
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: purchaseRows.length > 0 ? "strong" : "medium",
balance_confirmed: purchaseRows.length > 0
evidence_strength: agingItems.length > 0 ? "strong" : "medium",
balance_confirmed: agingItems.length > 0
}
};
}
@@ -249,6 +249,9 @@ function hasAddressFollowupContextSignal(text) {
if (!normalized) {
return false;
}
if (/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(normalized)) {
return true;
}
if (hasAllTimeHint(normalized)) {
return true;
}
@@ -2579,6 +2579,9 @@ function hasAddressFollowupContextSignal(userMessage) {
if (samples.length === 0) {
return false;
}
if (samples.some((sample) => /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(sample))) {
return true;
}
const hasAny = (pattern) => samples.some((sample) => pattern.test(sample));
const hasMarker = () => samples.some((sample) => hasFollowupMarker(sample));
const hasPointer = () => samples.some((sample) => hasReferentialPointer(sample));