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

This commit is contained in:
dctouch 2026-04-14 15:11:46 +03:00
parent d41819eabd
commit 27bd4f18fb
16 changed files with 486 additions and 46 deletions

View File

@ -39,6 +39,9 @@ Rules:
- If the system answered a weaker question than the user asked, say so explicitly.
- Treat colloquial/slang wording, typo variants, and UI-generated selected-object follow-ups as first-class coverage, not optional polish.
- If the domain works only for one curated phrasing but breaks for realistic conversational or UI-originated follow-ups, call that out as a real defect and lower the score.
- In cascading scenarios, verify temporal continuity explicitly: if the user says `на эту дату` / `на ту дату`, compare the carried date or period in debug filters to the originating turn and call out any drift as a defect.
- Verify answer granularity explicitly: if the user asked for item-level residues, do not accept a document-level dump as a correct answer.
- Verify sort/order semantics when the wording implies chronology or ranking, for example `старые закупки` should be oldest-first.
Quality score:
- Output one integer score from 0 to 100.

View File

@ -39,6 +39,9 @@ Hard rules:
- Stop early when the analyst sets `requires_user_decision = true` because the next step would otherwise require guessing a missing required observation, accepting a risky architecture fork, choosing a business-critical tradeoff, or pushing through a hacky / brittle / disproportionally complex fix.
- Treat true runtime or 1C availability failures as `blocked`, not as a normal low-score iteration.
- For follow-up-heavy domains, capture and rerun at least one colloquial/slang variant and one UI-generated selected-object follow-up variant instead of validating only canonical wording.
- For cascading date-sensitive scenarios, rerun at least one `на эту дату` / `на ту дату` follow-up and verify that the originating date or period survives into debug filters.
- If the business question asks for residues/items/contracts but the answer switched to raw documents or movements, treat that as a real defect, not as acceptable detail.
- If the wording implies chronology or ranking such as `старые закупки`, verify oldest-first ordering explicitly.
Acceptance gate:
- accepted requires analyst quality_score >= 80

View File

@ -185,6 +185,9 @@ Accepted requires:
- Preserve successful baseline scenarios.
- Treat follow-up continuity as a state-machine problem, not a wording problem.
- Do not accept a domain as hardened if only canonical phrasing works while colloquial or UI-generated follow-up phrasing still breaks the exact contour.
- Treat temporal carryover loss in a cascading scenario as a real regression: if the user says `на эту дату` / `на ту дату`, the analyst must verify that the exact carried date or period survived into `extracted_filters`.
- Treat answer-shape mismatch as a scoring defect: if the user asked for items / residues / contracts, do not accept an answer that switched to raw documents, movements, or another lower-level object without saying so explicitly.
- Treat ordering semantics as part of correctness when the wording implies ranking or chronology, for example `старые закупки` => oldest-first rather than newest-first.
## Domain-specific framing

View File

@ -0,0 +1,17 @@
{
"first": {
"reply_type": "factual",
"content": null,
"technical": null
},
"second": {
"reply_type": "factual",
"content": null,
"technical": null
},
"third": {
"reply_type": "factual",
"content": null,
"technical": null
}
}

View File

@ -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;

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: {

View File

@ -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
}
};
}

View File

@ -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;
}

View File

@ -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));

View File

@ -1243,6 +1243,9 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
const filters: AddressFilterSet = {
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;

View File

@ -1896,6 +1896,22 @@ function composeAutoBroadenedPeriodPrefix(
return "По заданному периоду строк не найдено; показаны ближайшие доступные данные по этому якорю.";
}
function injectNoticeAfterLeadLine(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;
}
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: AddressLimitedReasonCategory): AddressRuntimeReadiness {
if (category === "empty_match" || category === "missing_anchor") {
return "LIVE_QUERYABLE_WITH_LIMITS";
@ -3484,7 +3500,7 @@ export 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: inferReplyType(broadenedFactual.responseType),
response_type: broadenedFactual.responseType,
debug: {

View File

@ -1069,6 +1069,143 @@ function formatInventoryTraceRows(rows: ComposeStageRow[], limit = 10): string[]
});
}
interface InventoryAgingByItemAggregate {
item: string;
warehouse: string | null;
organization: string | null;
firstPurchasePeriod: string | null;
lastPurchasePeriod: string | null;
operations: number;
documentCount: number;
counterparties: string[];
ageDays: number | null;
}
function buildInventoryAgingByItemAggregate(
rows: ComposeStageRow[],
asOfDate: string
): InventoryAgingByItemAggregate[] {
const byItem = new Map<
string,
{
item: string;
warehouse: string | null;
organization: string | null;
firstPurchasePeriod: string | null;
lastPurchasePeriod: string | null;
operations: number;
documents: Set<string>;
counterparties: Set<string>;
}
>();
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: InventoryAgingByItemAggregate[], asOfDate: string, limit = 10): string[] {
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(" | ");
});
}
interface CounterpartyRiskAggregate {
name: string;
totalAmount: number;
@ -3920,7 +4057,14 @@ export function composeFactualReply(
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: string[] = [
directAnswerLine,
`Собран подтвержденный закупочный след по товару ${itemLabel} до ${formatDateRu(asOfDate)}.`,
"",
"Блок 1. Статус результата",
@ -4002,44 +4146,52 @@ export function composeFactualReply(
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: string[] = [
`Собран 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
}
};
}

View File

@ -311,6 +311,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
if (!normalized) {
return false;
}
if (/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(normalized)) {
return true;
}
if (hasAllTimeHint(normalized)) {
return true;
}

View File

@ -2536,6 +2536,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));

View File

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
describe("inventory aging follow-up", () => {
it("keeps carried date window for 'на эту дату' aging follow-up", () => {
const result = runAddressDecomposeStage("Какие остатки по товарам на эту дату относятся к старым закупкам", {
previous_intent: "inventory_purchase_provenance_for_item",
previous_filters: {
item: "Четки Пост (84*117)",
as_of_date: "2021-09-30",
period_from: "2021-09-01",
period_to: "2021-09-30",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
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.period_from).toBe("2021-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2021-09-30");
expect(result?.filters.extracted_filters.sort).toBe("period_asc");
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
expect(result?.baseReasons).toContain("period_from_followup_context");
});
it("renders aging answer as oldest-first item list instead of raw documents", () => {
const reply = composeFactualReply(
"inventory_aging_by_purchase_date",
[
{
period: "2019-02-06T00:00:00Z",
registrator: "Поступление товаров и услуг 00000000004 от 06.02.2019 0:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 833.33,
analytics: ["Четки Пост (84*117)", "Основной склад", "Торговый дом \\Союз\\"],
item: "Четки Пост (84*117)",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
{
period: "2020-11-20T12:00:00Z",
registrator: "Поступление товаров и услуг 00000000030 от 20.11.2020 12:00:00",
account_dt: "41.01",
account_kt: "60.01",
amount: 7250,
analytics: ["Зеркало для инвалидов поворотное травмобезопасное", "Основной склад", "ВИЗАНТИЯ"],
item: "Зеркало для инвалидов поворотное травмобезопасное",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
}
],
{
asOfDate: "2021-09-30",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("К старым закупкам на 30.09.2021");
expect(reply.text).toContain("Блок 3. Позиции от самых старых закупок");
expect(reply.text).not.toContain("Блок 3. Опорные документы");
expect(reply.text.indexOf("1. Четки Пост (84*117)")).toBeGreaterThan(-1);
expect(reply.text.indexOf("2. Зеркало для инвалидов поворотное травмобезопасное")).toBeGreaterThan(
reply.text.indexOf("1. Четки Пост (84*117)")
);
});
});

View File

@ -22,7 +22,7 @@ afterEach(() => {
});
describe("inventory selected-object follow-up", () => {
it("auto-broadens dated stock follow-up window for inventory provenance", async () => {
it("inherits dated stock window for selected-object provenance and then auto-broadens history", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
@ -79,16 +79,36 @@ describe("inventory selected-object follow-up", () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар'
'По выбранному объекту "Кромка с клеем 33 альмандин 137 м | склад: Основной склад | количество: 1,000 | стоимость: 165,83 ₽ | организация: ООО \\\\Альтернатива Плюс\\\\ | дата строки: 2021-03-31T23:59:59Z": От какого поставщика куплен товар',
{
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
as_of_date: "2021-03-31",
period_from: "2021-03-01",
period_to: "2021-03-31",
warehouse: "Основной склад",
organization: "ООО \\Альтернатива Плюс\\"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
}
}
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м");
expect(result?.debug.extracted_filters?.as_of_date).toBe("2021-03-31");
expect(result?.debug.extracted_filters?.period_from).toBe("2021-03-01");
expect(result?.debug.extracted_filters?.period_to).toBe("2021-03-31");
expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data");
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\");
const replyLines = String(result?.reply_text ?? "").split("\n");
expect(replyLines[0]).toContain("Товар Кромка с клеем 33 альмандин 137 м");
expect(replyLines[0]).toContain("Торговый дом \\Союз МСК\\");
expect(replyLines[1]).toContain("По окну 2021-03-01..2021-03-31 строк не найдено");
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
});
});