diff --git a/.codex/agents/domain_analyst.toml b/.codex/agents/domain_analyst.toml index f49f74d..30bfd62 100644 --- a/.codex/agents/domain_analyst.toml +++ b/.codex/agents/domain_analyst.toml @@ -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. diff --git a/.codex/agents/orchestrator.toml b/.codex/agents/orchestrator.toml index 1c5f02c..e49f37a 100644 --- a/.codex/agents/orchestrator.toml +++ b/.codex/agents/orchestrator.toml @@ -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 diff --git a/.codex/skills/domain-case-loop/SKILL.md b/.codex/skills/domain-case-loop/SKILL.md index 92c8f2b..cec1660 100644 --- a/.codex/skills/domain-case-loop/SKILL.md +++ b/.codex/skills/domain-case-loop/SKILL.md @@ -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 diff --git a/artifacts/tmp_live_rerun_inventory_sept2021.json b/artifacts/tmp_live_rerun_inventory_sept2021.json new file mode 100644 index 0000000..174e2cc --- /dev/null +++ b/artifacts/tmp_live_rerun_inventory_sept2021.json @@ -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 + } +} diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index eb1ddb7..1e0888e 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -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; diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index a9a85f7..1121b02 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -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: { diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index fc9d9ee..d71fab8 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -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 } }; } diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 7c280e6..a05298c 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -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; } diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index d237203..ab61525 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -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)); diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 625b227..b1eb296 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -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; diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index fb69fd0..93859f6 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -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: { diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 24d7e8c..30d48b8 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -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; + counterparties: Set; + } + >(); + 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 } }; } diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index b482460..8ab5227 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -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; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 2069970..e835648 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -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)); diff --git a/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts new file mode 100644 index 0000000..eb00eb1 --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventoryAgingFollowup.test.ts @@ -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)") + ); + }); +}); diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index d3f664b..2202a11 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -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); }); });