ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Починить каскад складских follow-up: удержать дату, поднять прямой ответ вверх и усилить analyst-loop
This commit is contained in:
parent
d41819eabd
commit
27bd4f18fb
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue