From 7f42d8ab50bc61715b7a7bf3f5fcd15cfb1a0c04 Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 17 Apr 2026 18:24:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A7=20=D0=90=D0=9F11=20-=20?= =?UTF-8?q?=D0=9E=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F:=20=D0=B4=D0=BE=D0=B6=D0=B0=D1=82=D1=8C=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=20=D0=BF=D0=BE=20selected-object=20continuity=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20accepted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../08 - current_status_audit_2026-04-17.md | 30 +- ...se4_inventory_answer_shape_continuity.json | 189 ++++++++ .../dist/services/addressFilterExtractor.js | 6 + .../dist/services/addressIntentResolver.js | 9 +- .../services/address_runtime/composeStage.js | 385 +-------------- .../address_runtime/decomposeStage.js | 12 +- .../address_runtime/inventoryReplyBuilders.js | 317 +++++++++++++ .../address_runtime/replyContracts.js | 39 ++ .../src/services/addressFilterExtractor.ts | 14 + .../src/services/addressIntentResolver.ts | 17 +- .../services/address_runtime/composeStage.ts | 416 +--------------- .../address_runtime/decomposeStage.ts | 24 +- .../address_runtime/inventoryReplyBuilders.ts | 446 ++++++++++++++++++ .../address_runtime/replyContracts.ts | 53 +++ .../tests/addressQueryRuntimeM23.test.ts | 26 + .../backend/tests/replyContracts.test.ts | 45 ++ .../data/autorun_generators/history.json | 221 ++++++++- ..._20260417150806_gen-ag04171508-760111.json | 129 +++++ ...s_20260416182626_gen-mo1t93wq-jy0453e.json | 5 +- ..._20260417150806_gen-ag04171508-760111.json | 49 ++ ..._saved_session_runtime_job-XDhN0VgANV.json | 45 ++ ..._saved_session_runtime_job-az4ZDEQptK.json | 114 +++++ 22 files changed, 1775 insertions(+), 816 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase4_inventory_answer_shape_continuity.json create mode 100644 llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js create mode 100644 llm_normalizer/backend/dist/services/address_runtime/replyContracts.js create mode 100644 llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts create mode 100644 llm_normalizer/backend/src/services/address_runtime/replyContracts.ts create mode 100644 llm_normalizer/backend/tests/replyContracts.test.ts create mode 100644 llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417150806_gen-ag04171508-760111.json create mode 100644 llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417150806_gen-ag04171508-760111.json create mode 100644 llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-XDhN0VgANV.json create mode 100644 llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-az4ZDEQptK.json diff --git a/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md index 13db863..61cdb53 100644 --- a/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md +++ b/docs/ARCH/11 - architecture_turnaround/08 - current_status_audit_2026-04-17.md @@ -25,16 +25,16 @@ This snapshot is based on: Latest graph rebuild: -- `5251 nodes` -- `11337 edges` -- `136 communities` +- `5261 nodes` +- `11347 edges` +- `134 communities` Most relevant current god nodes for turnaround `11`: 1. `resolveAddressIntent()` -2. `composeFactualReplyBody()` -3. `ChannelRegistry` -4. `CanonicalStore` +2. `ChannelRegistry` +3. `CanonicalStore` +4. `composeFactualReplyBody()` 5. `compactWhitespace()` The relevant conclusion is not that every god node is part of turnaround `11`. @@ -42,7 +42,7 @@ The relevant conclusion is not that every god node is part of turnaround `11`. The relevant conclusion is: - `resolveAddressIntent()` remains the main unresolved domain-intent concentration point; -- `composeFactualReplyBody()` now carries the remaining answer-shaping concentration after packaging extraction; +- `composeFactualReplyBody()` still carries answer-shaping pressure, but it is no longer a top-3 god node after the latest extractions; - `assistantService` still appears as a large coordinator-heavy community rather than a thin shell. ## What Is Already Real In Code @@ -126,7 +126,7 @@ This is enough to build targeted semantic packs that are not single-domain toy s ## Honest Phase Status -Estimated overall turnaround completion: `~88%` +Estimated overall turnaround completion: `~90%` ### Phase 0. Shared Baseline @@ -177,19 +177,21 @@ Remaining debt: ### Phase 4. Coverage / Evidence / Truth Gate Isolation -Status: `86%` +Status: `89%` Reason: - explicit truth and coverage/evidence contracts exist; - answer policy reads those contracts rather than rebuilding verdicts blindly from raw rows. - reply-packaging mechanics are now explicitly split into `address_runtime/replyPackaging.ts` instead of staying fully in `composeStage.ts`. +- named reply contracts and answer semantics presets now also live in `address_runtime/replyContracts.ts` instead of being rebuilt inline across major factual branches. +- inventory answer construction now has an explicit owner in `address_runtime/inventoryReplyBuilders.ts` instead of staying inline inside `composeFactualReplyBody()`. Remaining debt: -- `composeFactualReplyBody()` is still a major concentration point; +- `composeFactualReplyBody()` is still a major concentration point, but its graph pressure is lower than in the previous snapshot; - humanized blocked/limited semantics are not yet fully separated from answer semantics across all paths; -- `composeStage.ts` still remains too large even after packaging extraction. +- `composeStage.ts` still remains too large even after packaging extraction and inventory-family extraction. ### Phase 5. AssistantService Extraction @@ -247,6 +249,8 @@ Compared with the pre-turnaround baseline, the system is now materially better i - organization data-scope probing is no longer owned only by coordinator-local helper bodies; - debug payload assembly is now further isolated from top-level turn coordination; - reply formatting and reply-type classification now have an explicit owner outside `composeStage.ts`; +- confirmed-balance and heuristic-candidate reply contracts now have explicit builders instead of repeated inline `semantics` objects in major compose branches; +- inventory factual replies are now owned by a dedicated module rather than embedded directly in the central compose body; - architecture regressions can now be localized to route, transition, truth gate, coverage/evidence, boundary, or meta/memory layers. ## What Still Remains The Main Architectural Debt @@ -263,7 +267,7 @@ This means capability and contour growth still concentrate pressure there. ### 3. `composeFactualReplyBody()` is still too central -Truth contracts are now explicit, and reply packaging has started moving into its own owner, but final answer-shaping still retains too much architecture weight. +Truth contracts are now explicit, and reply packaging, reply contracts, and the inventory answer family have all started moving into their own owners, but final answer-shaping still retains too much architecture weight. This is the main remaining reason why user-facing humanization and limitation semantics are not completely isolated yet. @@ -284,7 +288,7 @@ But not every business family has reached the same contract maturity. The next honest architecture slice should be: 1. continue reducing `assistantService.ts` to a thinner coordinator; -2. continue isolating answer semantics further away from `composeFactualReply()` now that reply packaging has its own owner seam; +2. continue isolating answer semantics further away from `composeFactualReplyBody()` now that reply packaging and reply contracts have their own owner seams; 3. keep extending AGENT packs with mixed business + meta + interruption patterns instead of single-family smoke tests; 4. keep using scenario acceptance as the main sign-off rather than unit-test green status alone. diff --git a/docs/orchestration/address_truth_harness_phase4_inventory_answer_shape_continuity.json b/docs/orchestration/address_truth_harness_phase4_inventory_answer_shape_continuity.json new file mode 100644 index 0000000..a885033 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase4_inventory_answer_shape_continuity.json @@ -0,0 +1,189 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase4_inventory_answer_shape_continuity", + "domain": "inventory_answer_shape_and_continuity", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "description": "Targeted AGENT replay for the recent compose/inventory fixes: company clarification, inventory root restore, selected-object provenance, purchase date/documents follow-ups, and protection against technical garbage in user-facing replies.", + "bindings": { + "target_date": "2021-03-31", + "observed_organization": "ООО \\Альтернатива Плюс\\", + "observed_warehouse": "Основной склад", + "focus_item_current": "Столешница 600*3050*26 альмандин" + }, + "steps": [ + { + "step_id": "step_01_inventory_root_requires_company", + "title": "Inventory root asks to choose the company instead of hallucinating scope", + "question": "какие остатки на складе на март 2021", + "semantic_tags": [ + "inventory_root", + "company_clarification" + ], + "required_answer_patterns_any": [ + "(?i)компан|организац", + "(?i)уточни|уточните|выбери|выберите|по какой" + ], + "forbidden_answer_patterns": [ + "(?i)snapshot_items", + "(?i)bank_operations?_by_", + "(?i)technical_debug_payload|debug_payload_json|technical_breakdown_json", + "(?i)route[_ ]summary|trace_id|capability[_ ]id|intent", + "(?i)selected_object|focus_object|answer_object" + ] + }, + { + "step_id": "step_02_choose_company", + "title": "User chooses the company inside the same session", + "question": "давай по Альтернативе Плюс", + "semantic_tags": [ + "meta_scope", + "company_selection" + ], + "required_answer_patterns_any": [ + "(?i)альтернатива плюс|ООО \\\\Альтернатива Плюс\\\\|работаем по" + ], + "forbidden_answer_patterns": [ + "(?i)tool_gate_reason|living_router_reason|hard_meta_mode" + ] + }, + { + "step_id": "step_03_inventory_root_after_company", + "title": "Inventory root continues after the company choice", + "question": "тогда покажи остатки на март 2021", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "2021-03-31", + "period_from": "2021-03-01", + "period_to": "2021-03-31" + }, + "required_direct_answer_patterns_any": [ + "31\\.03\\.2021", + "(?i)на складе", + "(?i)столешница 600\\*3050\\*26 альмандин" + ], + "forbidden_direct_answer_patterns": [ + "(?i)snapshot_items", + "(?i)bank_operations?_by_", + "(?i)technical_debug_payload|debug_payload_json|technical_breakdown_json", + "(?i)route[_ ]summary|trace_id|capability[_ ]id|intent" + ] + }, + { + "step_id": "step_04_selected_item_supplier", + "title": "Selected-object supplier follow-up stays business-first", + "question": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "required_direct_answer_patterns_any": [ + "(?i)столешница 600\\*3050\\*26 альмандин", + "(?i)поставщик|поставил|куплен", + "(?i)союз|торговый дом" + ], + "forbidden_direct_answer_patterns": [ + "(?i)^на 31\\.03\\.2021 на складе", + "(?i)snapshot_items", + "(?i)bank_operations?_by_", + "(?i)selected_object|focus_object|answer_object", + "(?i)technical_debug_payload|debug_payload_json|technical_breakdown_json" + ] + }, + { + "step_id": "step_05_selected_item_purchase_date", + "title": "Natural follow-up keeps the same selected item", + "question": "а по этой позиции когда была закупка?", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "required_direct_answer_patterns_any": [ + "(?i)столешница 600\\*3050\\*26 альмандин|по этой позиции", + "(?i)дата|закупк|поступ" + ], + "forbidden_direct_answer_patterns": [ + "(?i)^на 31\\.03\\.2021 на складе", + "(?i)snapshot_items", + "(?i)bank_operations?_by_", + "(?i)selected_object|focus_object|answer_object", + "(?i)technical_debug_payload|debug_payload_json|technical_breakdown_json" + ] + }, + { + "step_id": "step_06_selected_item_documents", + "title": "Purchase documents stay in the same contour without technical dump", + "question": "покажи документы по этой позиции", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_purchase_documents_for_item" + ], + "required_direct_answer_patterns_any": [ + "(?i)столешница 600\\*3050\\*26 альмандин|по этой позиции", + "(?i)документ" + ], + "forbidden_direct_answer_patterns": [ + "(?i)snapshot_items", + "(?i)bank_operations?_by_", + "(?i)selected_object|focus_object|answer_object", + "(?i)technical_debug_payload|debug_payload_json|technical_breakdown_json", + "(?i)route[_ ]summary|trace_id|capability[_ ]id|intent" + ] + }, + { + "step_id": "step_07_inventory_same_date_restore", + "title": "Same-date restore returns to the March 2021 root snapshot", + "question": "покажи еще раз остатки на эту же дату", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "2021-03-31", + "period_from": "2021-03-01", + "period_to": "2021-03-31" + }, + "required_direct_answer_patterns_any": [ + "31\\.03\\.2021", + "(?i)на складе" + ], + "forbidden_direct_answer_patterns": [ + "(?i)transition_not_supported_by_capability", + "(?i)snapshot_items", + "(?i)bank_operations?_by_", + "(?i)technical_debug_payload|debug_payload_json|technical_breakdown_json" + ] + }, + { + "step_id": "step_08_memory_recap_business_only", + "title": "Memory recap stays business-first after the provenance chain", + "question": "а что мы уже выяснили по этой позиции?", + "semantic_tags": [ + "meta_memory" + ], + "required_answer_patterns_any": [ + "(?i)столешница 600\\*3050\\*26 альмандин|по этой позиции", + "(?i)поставщик|закупк|документ" + ], + "forbidden_answer_patterns": [ + "(?i)snapshot_items", + "(?i)bank_operations?_by_", + "(?i)selected_object|focus_object|answer_object", + "(?i)technical_debug_payload|debug_payload_json|technical_breakdown_json", + "(?i)route[_ ]summary|trace_id|capability[_ ]id|intent" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 2c7827e..5ea95d7 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -944,6 +944,12 @@ function isLowQualityInventoryItemAnchorValue(rawValue) { .map((token) => token.trim()) .filter(Boolean) .filter((token) => !lowQualityTokens.has(token)); + if (/^(?:(?:\u043a\u043e\u0433\u0434\u0430|when)\s+)?(?:(?:\u0431\u044b\u043b(?:\u0430|\u0438|\u043e)?|was|were)\s+)?(?:\u0437\u0430\u043a\u0443\u043f[\p{L}\p{N}_-]*|purchase\s+date|purchase)$/iu.test(value)) { + return true; + } + if (/^(?:(?:\u043a\u0442\u043e|who)\s+)?(?:(?:\u043d\u0430\u043c\s+)?(?:\u043f\u043e\u0441\u0442\u0430\u0432[\p{L}\p{N}_-]*|supplier)|supplier)$/iu.test(value)) { + return true; + } return meaningfulTokens.length === 0; } function normalizeInventoryItemAnchorForComparison(rawValue) { diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index a198cee..453151e 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -739,6 +739,10 @@ function hasCounterpartyActivityLifecycleSignal(text) { return hasCounterpartyLexeme && hasActivityLexeme && (hasTimeWindowLexeme || hasListVerb); } function hasCounterpartyShipmentItemFlowSignal(text) { + const hasSelectedObjectInventoryCue = /(?:по\s+этой\s+позици(?:и|я|ю)|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|selected\s+object|по\s+выбранному\s+объекту)/iu.test(text); + if (hasSelectedObjectInventoryCue) { + return false; + } const hasNamedTailAfterShipmentCue = /(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test(text); const hasPartySignal = hasPartyAnchorMention(text) || hasLooseByAnchorMention(text) || @@ -1397,8 +1401,9 @@ function hasInventoryProvenanceSignalV2(text) { return hasItemCue && hasSupplierCue && hasPurchaseCue; } function hasInventoryPurchaseDateSignal(text) { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); + const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test(text) || + /(?:когда\s+был(?:а|и|о)?\s+закупк\w*|когда\s+закупк\w*)/iu.test(text); return hasItemCue && hasPurchaseDateCue; } function hasInventoryPurchaseDocumentsSignalV2(text) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index f1de795..23eda66 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -5,6 +5,7 @@ exports.composeFactualReply = composeFactualReply; exports.inferReplyType = inferReplyType; const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher"); const replyPackaging_1 = require("./replyPackaging"); +const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders"); function uniqueStrings(values) { return Array.from(new Set(values .map((item) => item.trim()) @@ -2114,6 +2115,26 @@ function composeFactualReply(intent, rows, options = {}) { } function composeFactualReplyBody(intent, rows, options = {}) { const joinLines = replyPackaging_1.joinComposeReplyLines; + const inventoryReply = (0, inventoryReplyBuilders_1.composeInventoryReply)(intent, rows, options, { + resolvePayablesAsOfDate, + buildInventoryOnHandAggregate, + uniqueStrings, + formatDateRu, + formatNumberWithDots, + formatMoneyRub, + isInventoryPurchaseMovement, + summarizeInventoryTraceRows, + formatInventoryTraceRows, + hasInventoryPurchaseDateActionFocus, + inventoryTraceDateLabel, + extractInventoryCounterpartyCandidates, + buildInventoryAgingByItemAggregate, + formatInventoryAgingRows, + isInventorySaleMovement + }); + if (inventoryReply) { + return inventoryReply; + } if (intent === "document_type_and_account_section_profile") { const rowsByMarker = new Map(); for (const row of rows) { @@ -3308,370 +3329,6 @@ function composeFactualReplyBody(intent, rows, options = {}) { text: lines.join("\n") }; } - if (intent === "inventory_on_hand_as_of_date") { - const asOfDate = resolvePayablesAsOfDate(options); - const positions = buildInventoryOnHandAggregate(rows, asOfDate); - const uniqueItems = uniqueStrings(positions.map((item) => item.item)); - const uniqueWarehouses = uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)); - const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); - const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); - const directAnswerLine = positions.length > 0 - ? `На ${formatDateRu(asOfDate)} на складе подтверждено ${formatNumberWithDots(positions.length)} позиций с остатком на ${formatMoneyRub(totalAmount)}.` - : `На ${formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; - const lines = [directAnswerLine]; - if (positions.length > 0) { - lines.push("", "Позиции:"); - lines.push(...positions.slice(0, 20).map((item, index) => { - const warehouseLabel = item.warehouse ?? "склад не определен"; - const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; - const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : ""; - const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; - return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`; - })); - } - else { - lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); - } - lines.push("", "Подтверждение:", `- Дата среза: ${formatDateRu(asOfDate)}.`, "- Контур: остатки по счету 41.01 «Товары на складах».", `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.`); - if (rows.length !== positions.length) { - lines.push(`- Строк в подтвержденной выборке: ${formatNumberWithDots(rows.length)}.`); - } - return { - responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: positions.length > 0 ? "strong" : "medium", - balance_confirmed: true - } - }; - } - if (intent === "inventory_purchase_documents_for_item") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - const itemLabel = summary.item ?? "товар не определен"; - const directAnswerLine = purchaseRows.length <= 0 - ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` - : `По позиции ${itemLabel} найдено ${formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${formatDateRu(asOfDate)}.`; - const lines = [directAnswerLine]; - lines.push("", "Подтверждение:"); - lines.push(`- Дата верхней границы: ${formatDateRu(asOfDate)}.`); - lines.push(`- Операций поступления в выборке: ${formatNumberWithDots(purchaseRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- Поставщик: ${summary.counterparties[0]}.`); - } - else if (summary.counterparties.length > 1) { - lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - lines.push("", "Документы:"); - if (purchaseRows.length > 0) { - lines.push(...formatInventoryTraceRows(purchaseRows, 12)); - } - else { - lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре."); - } - return { - responseType: purchaseRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 ? "strong" : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - if (intent === "inventory_purchase_provenance_for_item") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - const itemLabel = summary.item ?? "товар не определен"; - const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null; - const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); - if (purchaseDateActionFocus) { - const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); - const lastPurchaseDate = inventoryTraceDateLabel(summary.lastPeriod); - const directAnswerLine = purchaseRows.length <= 0 || !summary.firstPeriod - ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` - : summary.firstPeriod === summary.lastPeriod - ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` - : boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.` - : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; - const lines = [directAnswerLine]; - if (purchaseRows.length > 0) { - lines.push("", "Подтверждение:"); - lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`); - if (summary.firstPeriod !== summary.lastPeriod) { - lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`); - } - if (summary.counterparties.length === 1) { - lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`); - } - else if (summary.counterparties.length > 1) { - lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - if (summary.documents.length > 0) { - lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); - } - if (boundedAsOfLabel) { - lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); - } - if (summary.counterparties.length > 1) { - lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`); - } - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 ? "strong" : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - const directAnswerLine = purchaseRows.length <= 0 - ? boundedAsOfLabel - ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.` - : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.` - : summary.counterparties.length === 1 - ? boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.` - : `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` - : boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.` - : `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`; - const lines = [directAnswerLine, "", "Подтверждение:"]; - if (purchaseRows.length > 0) { - lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); - lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); - lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); - lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`); - } - else if (summary.counterparties.length > 1) { - lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`); - } - else { - lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем."); - } - if (boundedAsOfLabel) { - lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); - } - if (summary.counterparties.length > 1) { - lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`); - } - } - else if (boundedAsOfLabel) { - lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); - } - if (summary.documents.length > 0) { - lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - if (intent === "inventory_supplier_stock_overlap_as_of_date") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0); - const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; - const directAnswerLine = summary.counterparties.length === 1 - ? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` - : `По складскому остатку ${warehouseLabel} поставщик в текущем exact-контуре не материализован.`; - const lines = [ - directAnswerLine, - `Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - `- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`, - "- Важно: без партионности этот контур показывает документально наблюдаемые supplier candidates, но не подменяет собой лот-level атрибуцию текущего остатка.", - "", - "Блок 2. Сводка", - `- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`, - `- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`, - `- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.` - ]; - if (summary.counterparties.length > 0) { - lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`); - } - else if (purchaseRows.length > 0) { - lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); - } - else { - lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза."); - } - if (unresolvedRows.length > 0) { - lines.push(`- Операций без явно материализованного поставщика: ${formatNumberWithDots(unresolvedRows.length)}.`); - } - if (purchaseRows.length > 0) { - lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 10)); - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - if (intent === "inventory_aging_by_purchase_date") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - 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 = [ - directAnswerLine, - `Собран exact-срез старых закупок для складского остатка на ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.", - "- Порядок: позиции отсортированы от самой старой первой закупки к более новым.", - "- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.", - "", - "Блок 2. Сводка", - `- Дата среза: ${formatDateRu(asOfDate)}.`, - `- Самая ранняя первая закупка среди позиций: ${inventoryTraceDateLabel(oldestPurchaseDate)}.`, - `- Самая поздняя найденная закупка в наблюдаемом следе: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Позиции в aging-срезе: ${formatNumberWithDots(agingItems.length)}.`, - `- Закупочных документов в наблюдаемом следе: ${formatNumberWithDots(summary.documents.length)}.`, - `- Закупочных операций в наблюдаемом следе: ${formatNumberWithDots(purchaseRows.length)}.` - ]; - if (oldestPurchaseAgeDays !== null) { - lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); - } - if (summary.counterparties.length > 0) { - lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - if (agingItems.length > 0) { - lines.push("", "Блок 3. Позиции от самых старых закупок", ...formatInventoryAgingRows(agingItems, asOfDate, 12)); - } - else { - lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза."); - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: agingItems.length > 0 ? "strong" : "medium", - balance_confirmed: agingItems.length > 0 - } - }; - } - if (intent === "inventory_sale_trace_for_item") { - const asOfDate = resolvePayablesAsOfDate(options); - const saleRows = rows.filter((row) => isInventorySaleMovement(row)); - const requestedItemHint = String(options.itemHint ?? "").trim(); - const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : []; - const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens); - const itemLabel = requestedItemHint || (summary.item ?? "товар не определен"); - const excludedCounterpartyTokens = [itemLabel]; - 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, "", "Подтверждение:"]; - lines.push(`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); - lines.push(`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); - lines.push(`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`); - lines.push(`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); - } - else if (summary.counterparties.length > 1) { - lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - else if (saleRows.length > 0) { - lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); - } - lines.push("", "Документы выбытия:"); - if (saleRows.length > 0) { - lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); - } - else { - lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); - } - return { - responseType: saleRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: saleRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", - balance_confirmed: saleRows.length > 0 - } - }; - } - if (intent === "inventory_purchase_to_sale_chain") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const saleRows = rows.filter((row) => isInventorySaleMovement(row)); - const purchaseSummary = summarizeInventoryTraceRows(purchaseRows); - const saleSummary = summarizeInventoryTraceRows(saleRows); - const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен"; - const directAnswerLine = purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 - ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` - : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; - const lines = [directAnswerLine, "", "Подтверждение:"]; - lines.push(`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`); - lines.push(`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`); - if (purchaseRows.length > 0 && saleRows.length > 0) { - lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); - } - else if (purchaseRows.length > 0) { - lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено."); - } - else if (saleRows.length > 0) { - lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена."); - } - else { - lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку."); - } - if (purchaseRows.length > 0) { - lines.push("", "Закупка:", `- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, ...formatInventoryTraceRows(purchaseRows, 6)); - } - if (saleRows.length > 0) { - lines.push("", "Продажа:", `- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, `- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, ...formatInventoryTraceRows(saleRows, 6)); - } - return { - responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 && saleRows.length > 0 ? "strong" : purchaseRows.length > 0 || saleRows.length > 0 ? "medium" : "weak", - balance_confirmed: purchaseRows.length > 0 || saleRows.length > 0 - } - }; - } if (intent === "open_contracts_confirmed_as_of_date") { const asOfDate = resolvePayablesAsOfDate(options); const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 03eef76..26b2230 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -454,7 +454,7 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters, return hasTemporalPatch; } function hasSelectedObjectInventorySignal(text) { - return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? "")); + return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? "")); } function hasSelectedObjectInlineSnapshotMetadata(text) { return /(?:дата\s+строки|строка\s+от|количество\s*:|стоимость\s*:|склад\s*:|организация\s*:|\|\s*(?:склад|количество|стоимость|организация|дата\s+строки)\s*:)/iu.test(String(text ?? "")); @@ -489,7 +489,9 @@ function hasInventoryProfitabilityFollowupCue(text) { } function hasInventoryPurchaseDateFollowupCue(text) { const value = String(text ?? ""); - return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(value) || (/когда/iu.test(value) && (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(value)); + return (/(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+была\s+закупк(?:а|и)|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test(value) || + /(?:по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)[\s\S]{0,40}(?:когда|дата\s+закупки|закупк))/iu.test(value) || + (/когда/iu.test(value) && (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(value))); } function hasBareInventoryPurchaseDateFollowupCue(text) { const normalized = String(text ?? "").trim().toLowerCase(); @@ -509,7 +511,7 @@ function hasAddressFollowupContextSignal(text) { if (!normalized) { return false; } - if (/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(normalized)) { + if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(normalized)) { return true; } if (hasAllTimeHint(normalized)) { @@ -1107,6 +1109,10 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo } if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { if (detectedIntent.intent === "unknown" || + detectedIntent.intent === "list_documents_by_counterparty" || + detectedIntent.intent === "list_documents_by_contract" || + detectedIntent.intent === "bank_operations_by_counterparty" || + detectedIntent.intent === "bank_operations_by_contract" || detectedIntent.intent === "inventory_purchase_provenance_for_item" || detectedIntent.intent === sourceIntent || detectedIntent.intent === "inventory_on_hand_as_of_date") { diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js new file mode 100644 index 0000000..593452e --- /dev/null +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -0,0 +1,317 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.composeInventoryReply = composeInventoryReply; +const replyContracts_1 = require("./replyContracts"); +function composeInventoryReply(intent, rows, options, deps) { + if (intent === "inventory_on_hand_as_of_date") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const positions = deps.buildInventoryOnHandAggregate(rows, asOfDate); + const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item)); + const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0)); + const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); + const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); + const directAnswerLine = positions.length > 0 + ? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.` + : `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; + const lines = [directAnswerLine]; + if (positions.length > 0) { + lines.push("", "Позиции:"); + lines.push(...positions.slice(0, 20).map((item, index) => { + const warehouseLabel = item.warehouse ?? "склад не определен"; + const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; + const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : ""; + const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${deps.formatNumberWithDots(item.quantity, 3)} | стоимость: ${deps.formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`; + })); + } + else { + lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + } + lines.push("", "Подтверждение:", `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, "- Контур: остатки по счету 41.01 «Товары на складах».", `- Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, `- Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, `- Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`); + if (rows.length !== positions.length) { + lines.push(`- Строк в подтвержденной выборке: ${deps.formatNumberWithDots(rows.length)}.`); + } + return positions.length > 0 + ? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong")) + : (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium")); + } + if (intent === "inventory_purchase_documents_for_item") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const itemLabel = summary.item ?? "товар не определен"; + const directAnswerLine = purchaseRows.length <= 0 + ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` + : `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`; + const lines = [directAnswerLine]; + lines.push("", "Подтверждение:"); + lines.push(`- Дата верхней границы: ${deps.formatDateRu(asOfDate)}.`); + lines.push(`- Операций поступления в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик: ${summary.counterparties[0]}.`); + } + else if (summary.counterparties.length > 1) { + lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + lines.push("", "Документы:"); + if (purchaseRows.length > 0) { + lines.push(...deps.formatInventoryTraceRows(purchaseRows, 12)); + } + else { + lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре."); + } + return purchaseRows.length > 0 + ? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong", true)) + : (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false)); + } + if (intent === "inventory_purchase_provenance_for_item") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const itemLabel = summary.item ?? "товар не определен"; + const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null; + const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage); + if (purchaseDateActionFocus) { + const firstPurchaseDate = deps.inventoryTraceDateLabel(summary.firstPeriod); + const lastPurchaseDate = deps.inventoryTraceDateLabel(summary.lastPeriod); + const directAnswerLine = purchaseRows.length <= 0 || !summary.firstPeriod + ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` + : summary.firstPeriod === summary.lastPeriod + ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.` + : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; + const lines = [directAnswerLine]; + if (purchaseRows.length > 0) { + lines.push("", "Подтверждение:"); + lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`); + if (summary.firstPeriod !== summary.lastPeriod) { + lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`); + } + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`); + } + else if (summary.counterparties.length > 1) { + lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + if (summary.documents.length > 0) { + lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); + } + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`); + } + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? "strong" : "medium", purchaseRows.length > 0)); + } + const directAnswerLine = purchaseRows.length <= 0 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.` + : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.` + : summary.counterparties.length === 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : summary.counterparties.length > 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.` + : `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`; + const lines = [directAnswerLine, "", "Подтверждение:"]; + if (purchaseRows.length > 0) { + lines.push(`- Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов поступления: ${deps.formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций поступления: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`); + } + else if (summary.counterparties.length > 1) { + lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`); + } + else { + lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем."); + } + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push(`- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.`); + } + } + else if (boundedAsOfLabel) { + lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.documents.length > 0) { + lines.push("", "Опорные документы:", ...deps.formatInventoryTraceRows(purchaseRows, 8)); + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); + } + if (intent === "inventory_supplier_stock_overlap_as_of_date") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); + const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; + const directAnswerLine = summary.counterparties.length === 1 + ? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` + : summary.counterparties.length > 1 + ? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` + : `По складскому остатку ${warehouseLabel} поставщик в текущем exact-контуре не материализован.`; + const lines = [ + directAnswerLine, + `Собран exact-срез supplier overlap для складского остатка до ${deps.formatDateRu(asOfDate)}.`, + "", + "Блок 1. Статус результата", + `- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`, + "- Важно: без партионности этот контур не доказывает конкретного владельца каждой партии, а показывает наблюдаемый закупочный след текущего остатка.", + "", + "Блок 2. Подтверждение", + `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `- Первая найденная дата закупочного движения: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, + `- Последняя найденная дата закупочного движения: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `- Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `- Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]; + if (summary.counterparties.length > 0) { + lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`); + } + else if (purchaseRows.length > 0) { + lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); + } + else { + lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза."); + } + if (unresolvedRows.length > 0) { + lines.push(`- Операций без явно материализованного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); + } + if (purchaseRows.length > 0) { + lines.push("", "Блок 3. Опорные документы", ...deps.formatInventoryTraceRows(purchaseRows, 10)); + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); + } + if (intent === "inventory_aging_by_purchase_date") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const agingItems = deps.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} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`) + .join("; "); + const directAnswerLine = agingItems.length > 0 + ? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` + : `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`; + const lines = [ + directAnswerLine, + `Собран exact-срез старых закупок для складского остатка на ${deps.formatDateRu(asOfDate)}.`, + "", + "Блок 1. Статус результата", + "- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.", + "- Порядок: позиции отсортированы от самой старой первой закупки к более новым.", + "- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.", + "", + "Блок 2. Сводка", + `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `- Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`, + `- Самая поздняя найденная закупка в наблюдаемом следе: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `- Позиции в aging-срезе: ${deps.formatNumberWithDots(agingItems.length)}.`, + `- Закупочных документов в наблюдаемом следе: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `- Закупочных операций в наблюдаемом следе: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]; + if (oldestPurchaseAgeDays !== null) { + lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); + } + if (summary.counterparties.length > 0) { + lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + if (agingItems.length > 0) { + lines.push("", "Блок 3. Позиции от самых старых закупок", ...deps.formatInventoryAgingRows(agingItems, asOfDate, 12)); + } + else { + lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза."); + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(agingItems.length > 0 ? "strong" : "medium", agingItems.length > 0)); + } + if (intent === "inventory_sale_trace_for_item") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); + const requestedItemHint = String(options.itemHint ?? "").trim(); + const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : []; + const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens); + const itemLabel = requestedItemHint || (summary.item ?? "товар не определен"); + const excludedCounterpartyTokens = [itemLabel]; + 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, "", "Подтверждение:"]; + lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); + } + else if (summary.counterparties.length > 1) { + lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + else if (saleRows.length > 0) { + lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); + } + lines.push("", "Документы выбытия:"); + if (saleRows.length > 0) { + lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); + } + else { + lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); + } + return saleRows.length > 0 + ? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(summary.counterparties.length > 0 ? "strong" : "medium", true)) + : (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium", false)); + } + if (intent === "inventory_purchase_to_sale_chain") { + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); + const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows); + const saleSummary = deps.summarizeInventoryTraceRows(saleRows); + const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен"; + const directAnswerLine = purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 + ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` + : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; + const lines = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Закупочных движений на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + lines.push(`- Движений выбытия со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); + if (purchaseRows.length > 0 && saleRows.length > 0) { + lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); + } + else if (purchaseRows.length > 0) { + lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено."); + } + else if (saleRows.length > 0) { + lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена."); + } + else { + lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку."); + } + if (purchaseRows.length > 0) { + lines.push("", "Закупка:", `- Первая дата: ${deps.inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, `- Последняя дата: ${deps.inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, ...deps.formatInventoryTraceRows(purchaseRows, 6)); + } + if (saleRows.length > 0) { + lines.push("", "Продажа:", `- Первая дата: ${deps.inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, `- Последняя дата: ${deps.inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, ...deps.formatInventoryTraceRows(saleRows, 6)); + } + return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 && saleRows.length > 0 + ? "strong" + : purchaseRows.length > 0 || saleRows.length > 0 + ? "medium" + : "weak", purchaseRows.length > 0 || saleRows.length > 0)); + } + return null; +} diff --git a/llm_normalizer/backend/dist/services/address_runtime/replyContracts.js b/llm_normalizer/backend/dist/services/address_runtime/replyContracts.js new file mode 100644 index 0000000..d3ca679 --- /dev/null +++ b/llm_normalizer/backend/dist/services/address_runtime/replyContracts.js @@ -0,0 +1,39 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.toComposeReplyText = toComposeReplyText; +exports.buildComposeReplyResult = buildComposeReplyResult; +exports.buildFactualSummaryReply = buildFactualSummaryReply; +exports.buildFactualListReply = buildFactualListReply; +exports.buildConfirmedBalanceSemantics = buildConfirmedBalanceSemantics; +exports.buildHeuristicCandidatesSemantics = buildHeuristicCandidatesSemantics; +const replyPackaging_1 = require("./replyPackaging"); +function toComposeReplyText(text) { + return Array.isArray(text) ? (0, replyPackaging_1.joinComposeReplyLines)(text) : String(text ?? ""); +} +function buildComposeReplyResult(responseType, text, semantics) { + return { + responseType, + text: toComposeReplyText(text), + ...(semantics ? { semantics } : {}) + }; +} +function buildFactualSummaryReply(text, semantics) { + return buildComposeReplyResult("FACTUAL_SUMMARY", text, semantics); +} +function buildFactualListReply(text, semantics) { + return buildComposeReplyResult("FACTUAL_LIST", text, semantics); +} +function buildConfirmedBalanceSemantics(evidenceStrength = "strong", balanceConfirmed = true) { + return { + result_mode: "confirmed_balance", + evidence_strength: evidenceStrength, + balance_confirmed: balanceConfirmed + }; +} +function buildHeuristicCandidatesSemantics(hasCandidates) { + return { + result_mode: "heuristic_candidates", + evidence_strength: hasCandidates ? "medium" : "weak", + balance_confirmed: false + }; +} diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 226b7ff..f809f93 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -1071,6 +1071,20 @@ export function isLowQualityInventoryItemAnchorValue(rawValue: string): boolean .map((token) => token.trim()) .filter(Boolean) .filter((token) => !lowQualityTokens.has(token)); + if ( + /^(?:(?:\u043a\u043e\u0433\u0434\u0430|when)\s+)?(?:(?:\u0431\u044b\u043b(?:\u0430|\u0438|\u043e)?|was|were)\s+)?(?:\u0437\u0430\u043a\u0443\u043f[\p{L}\p{N}_-]*|purchase\s+date|purchase)$/iu.test( + value + ) + ) { + return true; + } + if ( + /^(?:(?:\u043a\u0442\u043e|who)\s+)?(?:(?:\u043d\u0430\u043c\s+)?(?:\u043f\u043e\u0441\u0442\u0430\u0432[\p{L}\p{N}_-]*|supplier)|supplier)$/iu.test( + value + ) + ) { + return true; + } return meaningfulTokens.length === 0; } diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 77f1ab7..c07b9e1 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -861,6 +861,12 @@ function hasCounterpartyActivityLifecycleSignal(text: string): boolean { } function hasCounterpartyShipmentItemFlowSignal(text: string): boolean { + const hasSelectedObjectInventoryCue = /(?:по\s+этой\s+позици(?:и|я|ю)|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|selected\s+object|по\s+выбранному\s+объекту)/iu.test( + text + ); + if (hasSelectedObjectInventoryCue) { + return false; + } const hasNamedTailAfterShipmentCue = /(?:отгруж(?:ал|али|ено)|постав(?:лял|ляли|ил|или)|привоз(?:ил|или)|продал)\s+[a-zа-яё][a-zа-яё0-9._-]{2,}/iu.test( text @@ -1705,10 +1711,13 @@ function hasInventoryProvenanceSignalV2(text: string): boolean { } function hasInventoryPurchaseDateSignal(text: string): boolean { - const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); - const hasPurchaseDateCue = /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test( - text - ); + const hasItemCue = + /(?:товар|номенклатур|sku|item|product)/iu.test(text) || hasSelectedObjectInventoryCue(text); + const hasPurchaseDateCue = + /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|purchase\s+date)/iu.test( + text + ) || + /(?:когда\s+был(?:а|и|о)?\s+закупк\w*|когда\s+закупк\w*)/iu.test(text); return hasItemCue && hasPurchaseDateCue; } diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index ae26025..1b6df56 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -13,6 +13,7 @@ import { type ComposeReplyResult, type ComposeReplySemantics } from "./replyPackaging"; +import { composeInventoryReply } from "./inventoryReplyBuilders"; export type { ComposeFactualReplyOptions, ComposeReplySemantics } from "./replyPackaging"; @@ -2729,6 +2730,26 @@ function composeFactualReplyBody( options: ComposeFactualReplyOptions = {} ): ComposeReplyResult { const joinLines = joinComposeReplyLines; + const inventoryReply = composeInventoryReply(intent, rows, options, { + resolvePayablesAsOfDate, + buildInventoryOnHandAggregate, + uniqueStrings, + formatDateRu, + formatNumberWithDots, + formatMoneyRub, + isInventoryPurchaseMovement, + summarizeInventoryTraceRows, + formatInventoryTraceRows, + hasInventoryPurchaseDateActionFocus, + inventoryTraceDateLabel, + extractInventoryCounterpartyCandidates, + buildInventoryAgingByItemAggregate, + formatInventoryAgingRows, + isInventorySaleMovement + }); + if (inventoryReply) { + return inventoryReply; + } if (intent === "document_type_and_account_section_profile") { const rowsByMarker = new Map(); @@ -4251,401 +4272,6 @@ function composeFactualReplyBody( }; } - if (intent === "inventory_on_hand_as_of_date") { - const asOfDate = resolvePayablesAsOfDate(options); - const positions = buildInventoryOnHandAggregate(rows, asOfDate); - const uniqueItems = uniqueStrings(positions.map((item) => item.item)); - const uniqueWarehouses = uniqueStrings( - positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0) - ); - const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); - const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); - const directAnswerLine = - positions.length > 0 - ? `На ${formatDateRu(asOfDate)} на складе подтверждено ${formatNumberWithDots(positions.length)} позиций с остатком на ${formatMoneyRub(totalAmount)}.` - : `На ${formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; - const lines: string[] = [directAnswerLine]; - - if (positions.length > 0) { - lines.push("", "Позиции:"); - lines.push( - ...positions.slice(0, 20).map((item, index) => { - const warehouseLabel = item.warehouse ?? "склад не определен"; - const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; - const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : ""; - const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; - return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${formatNumberWithDots(item.quantity, 3)} | стоимость: ${formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`; - }) - ); - } else { - lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); - } - - lines.push( - "", - "Подтверждение:", - `- Дата среза: ${formatDateRu(asOfDate)}.`, - "- Контур: остатки по счету 41.01 «Товары на складах».", - `- Уникальных товаров: ${formatNumberWithDots(uniqueItems.length)}.`, - `- Уникальных складов: ${formatNumberWithDots(uniqueWarehouses.length)}.`, - `- Суммарное количество: ${formatNumberWithDots(totalQuantity, 3)}.` - ); - if (rows.length !== positions.length) { - lines.push(`- Строк в подтвержденной выборке: ${formatNumberWithDots(rows.length)}.`); - } - - return { - responseType: positions.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: positions.length > 0 ? "strong" : "medium", - balance_confirmed: true - } - }; - } - - if (intent === "inventory_purchase_documents_for_item") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - const itemLabel = summary.item ?? "товар не определен"; - const directAnswerLine = - purchaseRows.length <= 0 - ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` - : `По позиции ${itemLabel} найдено ${formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${formatDateRu(asOfDate)}.`; - const lines: string[] = [directAnswerLine]; - lines.push("", "Подтверждение:"); - lines.push(`- Дата верхней границы: ${formatDateRu(asOfDate)}.`); - lines.push(`- Операций поступления в выборке: ${formatNumberWithDots(purchaseRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- Поставщик: ${summary.counterparties[0]}.`); - } else if (summary.counterparties.length > 1) { - lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - lines.push("", "Документы:"); - if (purchaseRows.length > 0) { - lines.push(...formatInventoryTraceRows(purchaseRows, 12)); - } else { - lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре."); - } - return { - responseType: purchaseRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 ? "strong" : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - - if (intent === "inventory_purchase_provenance_for_item") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - const itemLabel = summary.item ?? "товар не определен"; - const boundedAsOfLabel = asOfDate ? formatDateRu(asOfDate) : null; - const purchaseDateActionFocus = hasInventoryPurchaseDateActionFocus(options.userMessage); - if (purchaseDateActionFocus) { - const firstPurchaseDate = inventoryTraceDateLabel(summary.firstPeriod); - const lastPurchaseDate = inventoryTraceDateLabel(summary.lastPeriod); - const directAnswerLine = - purchaseRows.length <= 0 || !summary.firstPeriod - ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` - : summary.firstPeriod === summary.lastPeriod - ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` - : boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.` - : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; - const lines: string[] = [directAnswerLine]; - if (purchaseRows.length > 0) { - lines.push("", "Подтверждение:"); - lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`); - if (summary.firstPeriod !== summary.lastPeriod) { - lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`); - } - if (summary.counterparties.length === 1) { - lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`); - } else if (summary.counterparties.length > 1) { - lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - if (summary.documents.length > 0) { - lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); - } - if (boundedAsOfLabel) { - lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); - } - if (summary.counterparties.length > 1) { - lines.push( - `- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.` - ); - } - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 ? "strong" : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - const directAnswerLine = - purchaseRows.length <= 0 - ? boundedAsOfLabel - ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.` - : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.` - : summary.counterparties.length === 1 - ? boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.` - : `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` - : `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` - : boundedAsOfLabel - ? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.` - : `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`; - const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; - if (purchaseRows.length > 0) { - lines.push(`- Первая найденная дата закупки: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); - lines.push(`- Последняя найденная дата закупки: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); - lines.push(`- Документов поступления: ${formatNumberWithDots(summary.documents.length)}.`); - lines.push(`- Операций поступления: ${formatNumberWithDots(purchaseRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`); - } else if (summary.counterparties.length > 1) { - lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`); - } else { - lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем."); - } - if (boundedAsOfLabel) { - lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); - } - if (summary.counterparties.length > 1) { - lines.push( - `- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.` - ); - } - } else if (boundedAsOfLabel) { - lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); - } - if (summary.documents.length > 0) { - lines.push("", "Опорные документы:", ...formatInventoryTraceRows(purchaseRows, 8)); - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: - purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - - if (intent === "inventory_supplier_stock_overlap_as_of_date") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - const unresolvedRows = purchaseRows.filter((row) => extractInventoryCounterpartyCandidates(row).length === 0); - const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; - const directAnswerLine = - summary.counterparties.length === 1 - ? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` - : summary.counterparties.length > 1 - ? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` - : `По складскому остатку ${warehouseLabel} поставщик в текущем exact-контуре не материализован.`; - const lines: string[] = [ - directAnswerLine, - `Собран exact-срез supplier overlap для складского остатка до ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - `- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`, - "- Важно: без партионности этот контур показывает документально наблюдаемые supplier candidates, но не подменяет собой лот-level атрибуцию текущего остатка.", - "", - "Блок 2. Сводка", - `- Первая найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.firstPeriod)}.`, - `- Последняя найденная дата закупочного движения: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Закупочных документов в выборке: ${formatNumberWithDots(summary.documents.length)}.`, - `- Закупочных операций в выборке: ${formatNumberWithDots(purchaseRows.length)}.` - ]; - if (summary.counterparties.length > 0) { - lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`); - } else if (purchaseRows.length > 0) { - lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); - } else { - lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза."); - } - if (unresolvedRows.length > 0) { - lines.push(`- Операций без явно материализованного поставщика: ${formatNumberWithDots(unresolvedRows.length)}.`); - } - if (purchaseRows.length > 0) { - lines.push("", "Блок 3. Опорные документы", ...formatInventoryTraceRows(purchaseRows, 10)); - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", - balance_confirmed: purchaseRows.length > 0 - } - }; - } - - if (intent === "inventory_aging_by_purchase_date") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const summary = summarizeInventoryTraceRows(purchaseRows); - 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[] = [ - directAnswerLine, - `Собран exact-срез старых закупок для складского остатка на ${formatDateRu(asOfDate)}.`, - "", - "Блок 1. Статус результата", - "- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.", - "- Порядок: позиции отсортированы от самой старой первой закупки к более новым.", - "- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.", - "", - "Блок 2. Сводка", - `- Дата среза: ${formatDateRu(asOfDate)}.`, - `- Самая ранняя первая закупка среди позиций: ${inventoryTraceDateLabel(oldestPurchaseDate)}.`, - `- Самая поздняя найденная закупка в наблюдаемом следе: ${inventoryTraceDateLabel(summary.lastPeriod)}.`, - `- Позиции в aging-срезе: ${formatNumberWithDots(agingItems.length)}.`, - `- Закупочных документов в наблюдаемом следе: ${formatNumberWithDots(summary.documents.length)}.`, - `- Закупочных операций в наблюдаемом следе: ${formatNumberWithDots(purchaseRows.length)}.` - ]; - if (oldestPurchaseAgeDays !== null) { - lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); - } - if (summary.counterparties.length > 0) { - lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } - if (agingItems.length > 0) { - lines.push("", "Блок 3. Позиции от самых старых закупок", ...formatInventoryAgingRows(agingItems, asOfDate, 12)); - } else { - lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза."); - } - return { - responseType: "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: agingItems.length > 0 ? "strong" : "medium", - balance_confirmed: agingItems.length > 0 - } - }; - } - - if (intent === "inventory_sale_trace_for_item") { - const asOfDate = resolvePayablesAsOfDate(options); - const saleRows = rows.filter((row) => isInventorySaleMovement(row)); - const requestedItemHint = String(options.itemHint ?? "").trim(); - const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : []; - const summary = summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens); - const itemLabel = requestedItemHint || (summary.item ?? "товар не определен"); - const excludedCounterpartyTokens = [itemLabel]; - 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, "", "Подтверждение:"]; - lines.push(`- Первая найденная дата выбытия: ${inventoryTraceDateLabel(summary.firstPeriod)}.`); - lines.push(`- Последняя найденная дата выбытия: ${inventoryTraceDateLabel(summary.lastPeriod)}.`); - lines.push(`- Документов выбытия: ${formatNumberWithDots(summary.documents.length)}.`); - lines.push(`- Операций выбытия: ${formatNumberWithDots(saleRows.length)}.`); - if (summary.counterparties.length === 1) { - lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); - } else if (summary.counterparties.length > 1) { - lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); - } else if (saleRows.length > 0) { - lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); - } - lines.push("", "Документы выбытия:"); - if (saleRows.length > 0) { - lines.push(...formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); - } else { - lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); - } - return { - responseType: saleRows.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: saleRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", - balance_confirmed: saleRows.length > 0 - } - }; - } - - if (intent === "inventory_purchase_to_sale_chain") { - const asOfDate = resolvePayablesAsOfDate(options); - const purchaseRows = rows.filter((row) => isInventoryPurchaseMovement(row)); - const saleRows = rows.filter((row) => isInventorySaleMovement(row)); - const purchaseSummary = summarizeInventoryTraceRows(purchaseRows); - const saleSummary = summarizeInventoryTraceRows(saleRows); - const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен"; - const directAnswerLine = - purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 - ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` - : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; - const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; - lines.push(`- Закупочных движений на 41.01: ${formatNumberWithDots(purchaseRows.length)}.`); - lines.push(`- Движений выбытия со счета 41.01: ${formatNumberWithDots(saleRows.length)}.`); - if (purchaseRows.length > 0 && saleRows.length > 0) { - lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); - } else if (purchaseRows.length > 0) { - lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено."); - } else if (saleRows.length > 0) { - lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена."); - } else { - lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку."); - } - if (purchaseRows.length > 0) { - lines.push( - "", - "Закупка:", - `- Первая дата: ${inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, - `- Последняя дата: ${inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, - ...formatInventoryTraceRows(purchaseRows, 6) - ); - } - if (saleRows.length > 0) { - lines.push( - "", - "Продажа:", - `- Первая дата: ${inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, - `- Последняя дата: ${inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, - ...formatInventoryTraceRows(saleRows, 6) - ); - } - return { - responseType: purchaseRows.length > 0 || saleRows.length > 0 ? "FACTUAL_SUMMARY" : "FACTUAL_SUMMARY", - text: joinLines(lines), - semantics: { - result_mode: "confirmed_balance", - evidence_strength: purchaseRows.length > 0 && saleRows.length > 0 ? "strong" : purchaseRows.length > 0 || saleRows.length > 0 ? "medium" : "weak", - balance_confirmed: purchaseRows.length > 0 || saleRows.length > 0 - } - }; - } - if (intent === "open_contracts_confirmed_as_of_date") { const asOfDate = resolvePayablesAsOfDate(options); const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index e280869..16d37c1 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -580,7 +580,9 @@ function shouldRestoreInventoryRootFrame( } function hasSelectedObjectInventorySignal(text: string): boolean { - return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? "")); + return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + String(text ?? "") + ); } function hasSelectedObjectInlineSnapshotMetadata(text: string): boolean { @@ -629,9 +631,13 @@ export function hasInventoryProfitabilityFollowupCue(text: string): boolean { export function hasInventoryPurchaseDateFollowupCue(text: string): boolean { const value = String(text ?? ""); - return /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test( - value - ) || (/когда/iu.test(value) && hasInventoryPurchaseStem(value)); + return ( + /(?:когда\s+(?:примерно\s+)?(?:мы\s+)?купили|когда\s+был\s+куплен|когда\s+куплен|когда\s+была\s+закупк(?:а|и)|когда\s+это\s+купили|когда\s+эту\s+позицию\s+купили|когда\s+ее\s+купили|дата\s+закупки|purchase\s+date)/iu.test( + value + ) || + /(?:по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)[\s\S]{0,40}(?:когда|дата\s+закупки|закупк))/iu.test(value) || + (/когда/iu.test(value) && hasInventoryPurchaseStem(value)) + ); } export function hasBareInventoryPurchaseDateFollowupCue(text: string): boolean { @@ -657,7 +663,11 @@ export function hasAddressFollowupContextSignal(text: string): boolean { if (!normalized) { return false; } - if (/(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(normalized)) { + if ( + /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + normalized + ) + ) { return true; } if (hasAllTimeHint(normalized)) { @@ -1373,6 +1383,10 @@ function deriveIntentWithFollowupContext( if (inventorySelectedObjectFollowup && hasInventoryPurchaseDateFollowupCue(normalizedMessage)) { if ( detectedIntent.intent === "unknown" || + detectedIntent.intent === "list_documents_by_counterparty" || + detectedIntent.intent === "list_documents_by_contract" || + detectedIntent.intent === "bank_operations_by_counterparty" || + detectedIntent.intent === "bank_operations_by_contract" || detectedIntent.intent === "inventory_purchase_provenance_for_item" || detectedIntent.intent === sourceIntent || detectedIntent.intent === "inventory_on_hand_as_of_date" diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts new file mode 100644 index 0000000..245ed8c --- /dev/null +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -0,0 +1,446 @@ +import type { AddressIntent } from "../../types/addressQuery"; +import { + buildConfirmedBalanceSemantics, + buildFactualListReply, + buildFactualSummaryReply +} from "./replyContracts"; +import type { ComposeReplyResult } from "./replyPackaging"; +import type { ComposeStageRow } from "./composeStage"; + +interface InventoryComposeOptions { + userMessage?: string; + itemHint?: string; + asOfDate?: string; + periodFrom?: string; + periodTo?: string; +} + +interface InventoryOnHandAggregate { + item: string; + warehouse: string | null; + organization: string | null; + quantity: number; + amount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + sourceRefs: string[]; +} + +interface InventoryTraceSummary { + item: string | null; + warehouses: string[]; + organizations: string[]; + counterparties: string[]; + documents: string[]; + firstPeriod: string | null; + lastPeriod: string | null; + totalAmount: number; +} + +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; +} + +interface InventoryReplyDeps { + resolvePayablesAsOfDate: (options: InventoryComposeOptions) => string; + buildInventoryOnHandAggregate: (rows: ComposeStageRow[], asOfDate: string) => InventoryOnHandAggregate[]; + uniqueStrings: (values: string[]) => string[]; + formatDateRu: (isoDate: string) => string; + formatNumberWithDots: (value: number, fractionDigits?: number) => string; + formatMoneyRub: (value: number) => string; + isInventoryPurchaseMovement: (row: ComposeStageRow) => boolean; + summarizeInventoryTraceRows: (rows: ComposeStageRow[], excludedCounterpartyTokens?: string[]) => InventoryTraceSummary; + formatInventoryTraceRows: (rows: ComposeStageRow[], limit?: number, excludedCounterpartyTokens?: string[]) => string[]; + hasInventoryPurchaseDateActionFocus: (userMessage: string | null | undefined) => boolean; + inventoryTraceDateLabel: (value: string | null) => string; + extractInventoryCounterpartyCandidates: (row: ComposeStageRow, excludedTokens?: string[]) => string[]; + buildInventoryAgingByItemAggregate: (rows: ComposeStageRow[], asOfDate: string) => InventoryAgingByItemAggregate[]; + formatInventoryAgingRows: (items: InventoryAgingByItemAggregate[], asOfDate: string, limit?: number) => string[]; + isInventorySaleMovement: (row: ComposeStageRow) => boolean; +} + +export function composeInventoryReply( + intent: AddressIntent, + rows: ComposeStageRow[], + options: InventoryComposeOptions, + deps: InventoryReplyDeps +): ComposeReplyResult | null { + if (intent === "inventory_on_hand_as_of_date") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const positions = deps.buildInventoryOnHandAggregate(rows, asOfDate); + const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item)); + const uniqueWarehouses = deps.uniqueStrings( + positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0) + ); + const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0); + const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0); + const directAnswerLine = + positions.length > 0 + ? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.` + : `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; + const lines: string[] = [directAnswerLine]; + + if (positions.length > 0) { + lines.push("", "Позиции:"); + lines.push( + ...positions.slice(0, 20).map((item, index) => { + const warehouseLabel = item.warehouse ?? "склад не определен"; + const organizationLabel = item.organization ? ` | организация: ${item.organization}` : ""; + const periodLabel = item.lastPeriod ? ` | дата строки: ${item.lastPeriod}` : ""; + const refsLabel = item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.item} | склад: ${warehouseLabel} | количество: ${deps.formatNumberWithDots(item.quantity, 3)} | стоимость: ${deps.formatMoneyRub(item.amount)}${organizationLabel}${periodLabel}${refsLabel}`; + }) + ); + } else { + lines.push("", "Позиции:", "- На дату среза товары с ненулевым остатком по счету 41.01 не найдены."); + } + + lines.push( + "", + "Подтверждение:", + `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, + "- Контур: остатки по счету 41.01 «Товары на складах».", + `- Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`, + `- Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`, + `- Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.` + ); + if (rows.length !== positions.length) { + lines.push(`- Строк в подтвержденной выборке: ${deps.formatNumberWithDots(rows.length)}.`); + } + + return positions.length > 0 + ? buildFactualListReply(lines, buildConfirmedBalanceSemantics("strong")) + : buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium")); + } + + if (intent === "inventory_purchase_documents_for_item") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const itemLabel = summary.item ?? "товар не определен"; + const directAnswerLine = + purchaseRows.length <= 0 + ? `По позиции ${itemLabel} подтвержденные документы закупки в доступном контуре не найдены.` + : `По позиции ${itemLabel} найдено ${deps.formatNumberWithDots(summary.documents.length)} подтвержденных документов закупки до ${deps.formatDateRu(asOfDate)}.`; + const lines: string[] = [directAnswerLine]; + lines.push("", "Подтверждение:"); + lines.push(`- Дата верхней границы: ${deps.formatDateRu(asOfDate)}.`); + lines.push(`- Операций поступления в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик: ${summary.counterparties[0]}.`); + } else if (summary.counterparties.length > 1) { + lines.push(`- В закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + lines.push("", "Документы:"); + if (purchaseRows.length > 0) { + lines.push(...deps.formatInventoryTraceRows(purchaseRows, 12)); + } else { + lines.push("- По выбранному товару не найдено проводок поступления на 41.01 в доступном контуре."); + } + return purchaseRows.length > 0 + ? buildFactualListReply(lines, buildConfirmedBalanceSemantics("strong", true)) + : buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false)); + } + + if (intent === "inventory_purchase_provenance_for_item") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const itemLabel = summary.item ?? "товар не определен"; + const boundedAsOfLabel = asOfDate ? deps.formatDateRu(asOfDate) : null; + const purchaseDateActionFocus = deps.hasInventoryPurchaseDateActionFocus(options.userMessage); + if (purchaseDateActionFocus) { + const firstPurchaseDate = deps.inventoryTraceDateLabel(summary.firstPeriod); + const lastPurchaseDate = deps.inventoryTraceDateLabel(summary.lastPeriod); + const directAnswerLine = + purchaseRows.length <= 0 || !summary.firstPeriod + ? `По позиции ${itemLabel} подтвержденная дата закупки в доступном контуре не найдена.` + : summary.firstPeriod === summary.lastPeriod + ? `Позиция ${itemLabel} куплена ${firstPurchaseDate}.` + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.` + : `По позиции ${itemLabel} подтвержденный диапазон закупок: с ${firstPurchaseDate} по ${lastPurchaseDate}.`; + const lines: string[] = [directAnswerLine]; + if (purchaseRows.length > 0) { + lines.push("", "Подтверждение:"); + lines.push(`- Первая подтвержденная дата закупки: ${firstPurchaseDate}.`); + if (summary.firstPeriod !== summary.lastPeriod) { + lines.push(`- Последняя подтвержденная дата закупки: ${lastPurchaseDate}.`); + } + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в доступном закупочном следе: ${summary.counterparties[0]}.`); + } else if (summary.counterparties.length > 1) { + lines.push(`- В доступном закупочном следе найдено несколько поставщиков: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + if (summary.documents.length > 0) { + lines.push(`- Первый подтверждающий документ: ${summary.documents[0]}.`); + } + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push( + `- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.` + ); + } + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics(purchaseRows.length > 0 ? "strong" : "medium", purchaseRows.length > 0) + ); + } + + const directAnswerLine = + purchaseRows.length <= 0 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} подтвержденные закупочные документы до ${boundedAsOfLabel} не найдены.` + : `По позиции ${itemLabel} подтвержденные закупочные документы не найдены.` + : summary.counterparties.length === 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : `По позиции ${itemLabel} подтвержден поставщик: ${summary.counterparties[0]}.` + : summary.counterparties.length > 1 + ? boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : `По позиции ${itemLabel} однозначный поставщик не подтвержден: в документах встречаются ${summary.counterparties.slice(0, 4).join("; ")}.` + : boundedAsOfLabel + ? `По позиции ${itemLabel} до ${boundedAsOfLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.` + : `По позиции ${itemLabel} закупочные документы найдены, но поставщик в них не выделен отдельным полем.`; + const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; + if (purchaseRows.length > 0) { + lines.push(`- Первая найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата закупки: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов поступления: ${deps.formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций поступления: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- Поставщик в найденных документах: ${summary.counterparties[0]}.`); + } else if (summary.counterparties.length > 1) { + lines.push(`- Поставщики в найденных документах: ${summary.counterparties.slice(0, 6).join("; ")}.`); + } else { + lines.push("- Закупочные документы найдены, но поставщик в них не выделен отдельным полем."); + } + if (boundedAsOfLabel) { + lines.push(`- Для ответа учтены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.counterparties.length > 1) { + lines.push( + `- Без партионного учета нельзя однозначно связать остаток${boundedAsOfLabel ? ` на ${boundedAsOfLabel}` : ""} с одним конкретным поступлением.` + ); + } + } else if (boundedAsOfLabel) { + lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); + } + if (summary.documents.length > 0) { + lines.push("", "Опорные документы:", ...deps.formatInventoryTraceRows(purchaseRows, 8)); + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics( + purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium", + purchaseRows.length > 0 + ) + ); + } + + if (intent === "inventory_supplier_stock_overlap_as_of_date") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const unresolvedRows = purchaseRows.filter((row) => deps.extractInventoryCounterpartyCandidates(row).length === 0); + const warehouseLabel = summary.warehouses[0] ?? "не указанного склада"; + const directAnswerLine = + summary.counterparties.length === 1 + ? `По складскому остатку ${warehouseLabel} выявлен поставщик: ${summary.counterparties[0]}.` + : summary.counterparties.length > 1 + ? `По складскому остатку ${warehouseLabel} найдено несколько поставщиков: ${summary.counterparties.slice(0, 6).join("; ")}.` + : `По складскому остатку ${warehouseLabel} поставщик в текущем exact-контуре не материализован.`; + const lines: string[] = [ + directAnswerLine, + `Собран exact-срез supplier overlap для складского остатка до ${deps.formatDateRu(asOfDate)}.`, + "", + "Блок 1. Статус результата", + `- Контур: подтвержденные закупочные движения на 41.01, связанные со складом ${warehouseLabel}.`, + "- Важно: без партионности этот контур не доказывает конкретного владельца каждой партии, а показывает наблюдаемый закупочный след текущего остатка.", + "", + "Блок 2. Подтверждение", + `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `- Первая найденная дата закупочного движения: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`, + `- Последняя найденная дата закупочного движения: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `- Закупочных документов в выборке: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `- Закупочных операций в выборке: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]; + if (summary.counterparties.length > 0) { + lines.push(`- Найденные поставщики в наблюдаемом контуре: ${summary.counterparties.slice(0, 6).join("; ")}.`); + } else if (purchaseRows.length > 0) { + lines.push("- Закупочные движения найдены, но поставщик не материализован отдельным полем в текущем exact-контуре."); + } else { + lines.push("- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного складского среза."); + } + if (unresolvedRows.length > 0) { + lines.push(`- Операций без явно материализованного поставщика: ${deps.formatNumberWithDots(unresolvedRows.length)}.`); + } + if (purchaseRows.length > 0) { + lines.push("", "Блок 3. Опорные документы", ...deps.formatInventoryTraceRows(purchaseRows, 10)); + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics( + purchaseRows.length > 0 ? (summary.counterparties.length > 0 ? "strong" : "medium") : "medium", + purchaseRows.length > 0 + ) + ); + } + + if (intent === "inventory_aging_by_purchase_date") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const summary = deps.summarizeInventoryTraceRows(purchaseRows); + const agingItems = deps.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} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`) + .join("; "); + const directAnswerLine = + agingItems.length > 0 + ? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` + : `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`; + const lines: string[] = [ + directAnswerLine, + `Собран exact-срез старых закупок для складского остатка на ${deps.formatDateRu(asOfDate)}.`, + "", + "Блок 1. Статус результата", + "- Контур: показан item-level список товарных позиций с самым ранним документально наблюдаемым закупочным следом на 41.01.", + "- Порядок: позиции отсортированы от самой старой первой закупки к более новым.", + "- Важно: без партионности этот контур не доказывает возраст конкретного лота, а показывает документально наблюдаемый возраст закупочного следа по товарной позиции.", + "", + "Блок 2. Сводка", + `- Дата среза: ${deps.formatDateRu(asOfDate)}.`, + `- Самая ранняя первая закупка среди позиций: ${deps.inventoryTraceDateLabel(oldestPurchaseDate)}.`, + `- Самая поздняя найденная закупка в наблюдаемом следе: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`, + `- Позиции в aging-срезе: ${deps.formatNumberWithDots(agingItems.length)}.`, + `- Закупочных документов в наблюдаемом следе: ${deps.formatNumberWithDots(summary.documents.length)}.`, + `- Закупочных операций в наблюдаемом следе: ${deps.formatNumberWithDots(purchaseRows.length)}.` + ]; + if (oldestPurchaseAgeDays !== null) { + lines.push(`- Между самой ранней первой закупкой и датой среза прошло ${deps.formatNumberWithDots(oldestPurchaseAgeDays)} дн.`); + } + if (summary.counterparties.length > 0) { + lines.push(`- Поставщики, встречающиеся в наблюдаемом закупочном следе: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } + if (agingItems.length > 0) { + lines.push("", "Блок 3. Позиции от самых старых закупок", ...deps.formatInventoryAgingRows(agingItems, asOfDate, 12)); + } else { + lines.push("", "Блок 3. Позиции от самых старых закупок", "- В доступном exact-контуре не найдено закупочных движений по 41.01 для выбранного среза."); + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics(agingItems.length > 0 ? "strong" : "medium", agingItems.length > 0) + ); + } + + if (intent === "inventory_sale_trace_for_item") { + const asOfDate = deps.resolvePayablesAsOfDate(options); + const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); + const requestedItemHint = String(options.itemHint ?? "").trim(); + const provisionalExcludedTokens = requestedItemHint ? [requestedItemHint] : []; + const summary = deps.summarizeInventoryTraceRows(saleRows, provisionalExcludedTokens); + const itemLabel = requestedItemHint || (summary.item ?? "товар не определен"); + const excludedCounterpartyTokens = [itemLabel]; + 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, "", "Подтверждение:"]; + lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); + lines.push(`- Последняя найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.lastPeriod)}.`); + lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`); + lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`); + if (summary.counterparties.length === 1) { + lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); + } else if (summary.counterparties.length > 1) { + lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); + } else if (saleRows.length > 0) { + lines.push("- Документы выбытия найдены, но покупатель не материализован отдельным полем в текущем exact-контуре."); + } + lines.push("", "Документы выбытия:"); + if (saleRows.length > 0) { + lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); + } else { + lines.push("- По выбранному товару не найдено проводок выбытия со счета 41.01 в доступном контуре."); + } + return saleRows.length > 0 + ? buildFactualListReply( + lines, + buildConfirmedBalanceSemantics(summary.counterparties.length > 0 ? "strong" : "medium", true) + ) + : buildFactualSummaryReply(lines, buildConfirmedBalanceSemantics("medium", false)); + } + + if (intent === "inventory_purchase_to_sale_chain") { + const purchaseRows = rows.filter((row) => deps.isInventoryPurchaseMovement(row)); + const saleRows = rows.filter((row) => deps.isInventorySaleMovement(row)); + const purchaseSummary = deps.summarizeInventoryTraceRows(purchaseRows); + const saleSummary = deps.summarizeInventoryTraceRows(saleRows); + const itemLabel = purchaseSummary.item ?? saleSummary.item ?? "товар не определен"; + const directAnswerLine = + purchaseSummary.counterparties.length === 1 && saleSummary.counterparties.length === 1 + ? `По товару ${itemLabel} цепочка поставки и продажи связана с поставщиком ${purchaseSummary.counterparties[0]} и покупателем ${saleSummary.counterparties[0]}.` + : `По товару ${itemLabel} цепочка поставки и продажи подтверждена частично или разнообразно: детали идут следом.`; + const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; + lines.push(`- Закупочных движений на 41.01: ${deps.formatNumberWithDots(purchaseRows.length)}.`); + lines.push(`- Движений выбытия со счета 41.01: ${deps.formatNumberWithDots(saleRows.length)}.`); + if (purchaseRows.length > 0 && saleRows.length > 0) { + lines.push("- В текущем контуре найдены обе стороны цепочки: поступление и последующее выбытие."); + } else if (purchaseRows.length > 0) { + lines.push("- Найдена только закупочная часть цепочки; выбытие в текущем exact-контуре не подтверждено."); + } else if (saleRows.length > 0) { + lines.push("- Найдена только часть выбытия; закупочная часть цепочки в текущем exact-контуре не подтверждена."); + } else { + lines.push("- Для выбранного товара не найдено движений по 41.01, из которых можно собрать цепочку."); + } + if (purchaseRows.length > 0) { + lines.push( + "", + "Закупка:", + `- Первая дата: ${deps.inventoryTraceDateLabel(purchaseSummary.firstPeriod)}.`, + `- Последняя дата: ${deps.inventoryTraceDateLabel(purchaseSummary.lastPeriod)}.`, + ...deps.formatInventoryTraceRows(purchaseRows, 6) + ); + } + if (saleRows.length > 0) { + lines.push( + "", + "Продажа:", + `- Первая дата: ${deps.inventoryTraceDateLabel(saleSummary.firstPeriod)}.`, + `- Последняя дата: ${deps.inventoryTraceDateLabel(saleSummary.lastPeriod)}.`, + ...deps.formatInventoryTraceRows(saleRows, 6) + ); + } + return buildFactualSummaryReply( + lines, + buildConfirmedBalanceSemantics( + purchaseRows.length > 0 && saleRows.length > 0 + ? "strong" + : purchaseRows.length > 0 || saleRows.length > 0 + ? "medium" + : "weak", + purchaseRows.length > 0 || saleRows.length > 0 + ) + ); + } + + return null; +} diff --git a/llm_normalizer/backend/src/services/address_runtime/replyContracts.ts b/llm_normalizer/backend/src/services/address_runtime/replyContracts.ts new file mode 100644 index 0000000..1773aab --- /dev/null +++ b/llm_normalizer/backend/src/services/address_runtime/replyContracts.ts @@ -0,0 +1,53 @@ +import type { AddressEvidenceStrength, AddressResponseType } from "../../types/addressQuery"; +import { joinComposeReplyLines, type ComposeReplyResult, type ComposeReplySemantics } from "./replyPackaging"; + +export type ComposeReplyText = string | string[]; + +export function toComposeReplyText(text: ComposeReplyText): string { + return Array.isArray(text) ? joinComposeReplyLines(text) : String(text ?? ""); +} + +export function buildComposeReplyResult( + responseType: AddressResponseType, + text: ComposeReplyText, + semantics?: ComposeReplySemantics +): ComposeReplyResult { + return { + responseType, + text: toComposeReplyText(text), + ...(semantics ? { semantics } : {}) + }; +} + +export function buildFactualSummaryReply( + text: ComposeReplyText, + semantics?: ComposeReplySemantics +): ComposeReplyResult { + return buildComposeReplyResult("FACTUAL_SUMMARY", text, semantics); +} + +export function buildFactualListReply( + text: ComposeReplyText, + semantics?: ComposeReplySemantics +): ComposeReplyResult { + return buildComposeReplyResult("FACTUAL_LIST", text, semantics); +} + +export function buildConfirmedBalanceSemantics( + evidenceStrength: AddressEvidenceStrength = "strong", + balanceConfirmed = true +): ComposeReplySemantics { + return { + result_mode: "confirmed_balance", + evidence_strength: evidenceStrength, + balance_confirmed: balanceConfirmed + }; +} + +export function buildHeuristicCandidatesSemantics(hasCandidates: boolean): ComposeReplySemantics { + return { + result_mode: "heuristic_candidates", + evidence_strength: hasCandidates ? "medium" : "weak", + balance_confirmed: false + }; +} diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index c716e32..3e8728f 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -4120,6 +4120,27 @@ describe("address decompose stage follow-up carryover", () => { ).toBe(true); }); + it("promotes selected-item purchase-date wording 'а по этой позиции когда была закупка' into inventory provenance", () => { + const result = runAddressDecomposeStage("а по этой позиции когда была закупка?", { + previous_intent: "inventory_purchase_provenance_for_item", + previous_filters: { + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31", + item: "Столешница 600*3050*26 альмандин" + }, + previous_anchor_type: "unknown", + previous_anchor_value: null + }); + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.filters.extracted_filters.item).toBe("Столешница 600*3050*26 альмандин"); + expect( + result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") || + result?.intent.reasons.includes("inventory_purchase_date_signal_detected") + ).toBe(true); + }); + it("promotes conversational buyer follow-up into inventory sale trace with inherited date context", () => { const result = runAddressDecomposeStage("кому в итоге мы продали этот товар?", { previous_intent: "inventory_purchase_provenance_for_item", @@ -5141,6 +5162,11 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(result.intent).toBe("inventory_purchase_provenance_for_item"); }); + it("keeps selected-object purchase-date pronoun wording out of generic counterparty docs intent", () => { + const result = resolveAddressIntent("а по этой позиции когда была закупка?"); + expect(result.intent).toBe("inventory_purchase_provenance_for_item"); + }); + it("keeps direct item supplier questions in provenance intent even with current-stock tail", () => { const result = resolveAddressIntent( "От какого поставщика куплен товар Диван трехместный из текущего остатка на складе Основной склад?" diff --git a/llm_normalizer/backend/tests/replyContracts.test.ts b/llm_normalizer/backend/tests/replyContracts.test.ts new file mode 100644 index 0000000..a833387 --- /dev/null +++ b/llm_normalizer/backend/tests/replyContracts.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + buildConfirmedBalanceSemantics, + buildFactualListReply, + buildFactualSummaryReply, + buildHeuristicCandidatesSemantics +} from "../src/services/address_runtime/replyContracts"; + +describe("replyContracts", () => { + it("builds factual replies from line arrays", () => { + const result = buildFactualListReply(["Первая строка", "Вторая строка"], buildConfirmedBalanceSemantics("strong")); + + expect(result).toEqual({ + responseType: "FACTUAL_LIST", + text: "Первая строка\nВторая строка", + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }); + }); + + it("supports downgraded confirmed-balance semantics", () => { + expect(buildFactualSummaryReply("Нет подтвержденных строк", buildConfirmedBalanceSemantics("medium", false)).semantics).toEqual({ + result_mode: "confirmed_balance", + evidence_strength: "medium", + balance_confirmed: false + }); + }); + + it("builds heuristic candidate semantics from candidate presence", () => { + expect(buildHeuristicCandidatesSemantics(true)).toEqual({ + result_mode: "heuristic_candidates", + evidence_strength: "medium", + balance_confirmed: false + }); + expect(buildHeuristicCandidatesSemantics(false)).toEqual({ + result_mode: "heuristic_candidates", + evidence_strength: "weak", + balance_confirmed: false + }); + }); +}); diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index 989bd93..001dc8b 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -1,4 +1,49 @@ [ + { + "generation_id": "gen-ag04171508-760111", + "created_at": "2026-04-17T15:08:06+00:00", + "mode": "saved_user_sessions", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "count": 8, + "domain": "inventory_answer_shape_and_continuity", + "questions": [ + "какие остатки на складе на март 2021", + "давай по Альтернативе Плюс", + "тогда покажи остатки на март 2021", + "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "а по этой позиции когда была закупка?", + "покажи документы по этой позиции", + "покажи еще раз остатки на эту же дату", + "а что мы уже выяснили по этой позиции?" + ], + "generated_by": "codex_agent", + "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260417150806_gen-ag04171508-760111.json", + "context": { + "llm_provider": null, + "model": null, + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "autogen_personality_id": null, + "autogen_personality_prompt": null, + "source_session_id": null, + "saved_session_file": "assistant_saved_session_20260417150806_gen-ag04171508-760111.json", + "saved_case_set_kind": "agent_semantic_scenario", + "agent_run": true, + "agent_focus": "Targeted AGENT replay for the recent compose/inventory fixes: company clarification, inventory root restore, selected-object provenance, purchase date/documents follow-ups, and protection against technical garbage in user-facing replies.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase4_inventory_answer_shape_continuity.json", + "scenario_id": "address_truth_harness_phase4_inventory_answer_shape_continuity", + "semantic_tags": [ + "company_clarification", + "company_selection", + "inventory_root", + "meta_memory", + "meta_scope" + ], + "latest_acceptance": null + } + }, { "generation_id": "gen-ag04171326-15a132", "created_at": "2026-04-17T13:26:00+00:00", @@ -55,7 +100,8 @@ "selected_object_documents", "selected_object_supplier", "settlements_receivables" - ] + ], + "latest_acceptance": null } }, { @@ -109,7 +155,8 @@ "selected_object_documents", "selected_object_supplier", "settlements_receivables" - ] + ], + "latest_acceptance": null } }, { @@ -151,7 +198,10 @@ "agent_run": true, "agent_focus": "mixed documents meta and cross-domain replay for turnaround 11", "architecture_phase": "turnaround_11_phase7", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase7_meta_domain_mix.json" + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase7_meta_domain_mix.json", + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -193,7 +243,10 @@ "agent_run": true, "agent_focus": "mixed documents meta and cross-domain replay for turnaround 11", "architecture_phase": "turnaround_11_phase7", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase7_meta_domain_mix.json" + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase7_meta_domain_mix.json", + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -227,7 +280,10 @@ "agent_run": true, "agent_focus": "scenario acceptance gate over root selected-object restore and human meta", "architecture_phase": "turnaround_11_phase7", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase7_acceptance_gate_mix.json" + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase7_acceptance_gate_mix.json", + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -260,7 +316,10 @@ "agent_run": true, "agent_focus": "provider runtime axis hardening across chat meta and address boundaries", "architecture_phase": "turnaround_11_phase6", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase6_provider_axis_mix.json" + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase6_provider_axis_mix.json", + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -294,7 +353,10 @@ "agent_run": true, "agent_focus": "meta and memory recap replay over interrupted address context", "architecture_phase": "turnaround_11_phase5", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_meta_memory_mix.json" + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_meta_memory_mix.json", + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -327,7 +389,10 @@ "agent_run": true, "agent_focus": "coverage/evidence contract on factual, fallback, and root-reset branches", "architecture_phase": "turnaround_11_phase4", - "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase4_coverage_evidence_mix.json" + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase4_coverage_evidence_mix.json", + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -364,7 +429,14 @@ "autogen_personality_prompt": null, "source_session_id": "asst-kBU5iS0mEt", "saved_session_file": "assistant_saved_session_20260417070448_gen-mo2kcds2-tlqmvng.json", - "saved_case_set_kind": "assistant_session_scenario" + "saved_case_set_kind": "assistant_session_scenario", + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -372,7 +444,7 @@ "created_at": "2026-04-16T18:26:26.191Z", "mode": "saved_user_sessions", "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", - "count": 31, + "count": 30, "domain": null, "questions": [ "приветик - че как там дела", @@ -402,7 +474,6 @@ "что нам отгружать чепурнов? какой товар или услугу?", "какие остатки на складе на сегодня", "остатки на март 2016", - "это по общей базе уже нужен вывод не по чепурнову", "хвосты покажи по счету 60 на август 2022", "Есть ли остатки товара, которые закупались очень давно", "Какие конкретно номенклатуры формируют остаток по складу на май 2020" @@ -419,7 +490,14 @@ "autogen_personality_prompt": null, "source_session_id": "asst-NhPaZfbRYr", "saved_session_file": "assistant_saved_session_20260416182626_gen-mo1t93wq-jy0453e.json", - "saved_case_set_kind": "assistant_session_scenario" + "saved_case_set_kind": "assistant_session_scenario", + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -444,7 +522,14 @@ "autogen_personality_prompt": null, "source_session_id": "asst-iIpNheKZpP", "saved_session_file": "assistant_saved_session_20260416175150_gen-mo1s0m9z-ndf56a3.json", - "saved_case_set_kind": "assistant_session_scenario" + "saved_case_set_kind": "assistant_session_scenario", + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -483,7 +568,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов, долги нсд, счета, общий вывод по компании - контрагенты, заказчикам, скока денег кто принес и какие остатки по счетам, поиск документов, сальдо, банковские операции, незакрытые договора, документы по договорам, долги, Активность заказчиков по периодам, Поставщики и выплаты", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -522,7 +614,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов, долги нсд, счета, общий вывод по компании - контрагенты, заказчикам, скока денег кто принес и какие остатки по счетам, поиск документов, сальдо, банковские операции, незакрытые договора, документы по договорам, долги, Активность заказчиков по периодам, Поставщики и выплаты", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -556,7 +655,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов, долги нсд, счета, общий вывод по компании - контрагенты, заказчикам, скока денег кто принес и какие остатки по счетам, поиск документов, сальдо, банковские операции, незакрытые договора, документы по договорам, долги, Активность заказчиков по периодам, Поставщики и выплаты", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -590,7 +696,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов, долги нсд, счета, общий вывод по компании - контрагенты, заказчикам, скока денег кто принес и какие остатки по счетам, поиск документов, сальдо, банковские операции, незакрытые договора, документы по договорам, долги, Активность заказчиков по периодам, Поставщики и выплаты", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -634,7 +747,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов \\ нсд \\ счета \\ общий ваывод по компании - контрагенты заказчиким скока денег кто принес и тп", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -678,7 +798,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов \\ нсд \\ счета \\ общий ваывод по компании - контрагенты заказчиким скока денег кто принес и тп", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -707,7 +834,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл.", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -736,7 +870,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл.", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -770,7 +911,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл.", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -804,7 +952,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл.", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -848,7 +1003,14 @@ "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл.", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } }, { @@ -874,7 +1036,14 @@ "autogen_personality_prompt": "????????? ??????? ??????? ?? ????????.", "source_session_id": null, "saved_session_file": null, - "saved_case_set_kind": null + "saved_case_set_kind": null, + "agent_run": null, + "agent_focus": null, + "architecture_phase": null, + "source_spec_file": null, + "scenario_id": null, + "semantic_tags": null, + "latest_acceptance": null } } -] +] \ No newline at end of file diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417150806_gen-ag04171508-760111.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417150806_gen-ag04171508-760111.json new file mode 100644 index 0000000..d297756 --- /dev/null +++ b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417150806_gen-ag04171508-760111.json @@ -0,0 +1,129 @@ +{ + "saved_at": "2026-04-17T15:08:06+00:00", + "generation_id": "gen-ag04171508-760111", + "mode": "saved_user_sessions", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "agent_run": true, + "questions": [ + "какие остатки на складе на март 2021", + "давай по Альтернативе Плюс", + "тогда покажи остатки на март 2021", + "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "а по этой позиции когда была закупка?", + "покажи документы по этой позиции", + "покажи еще раз остатки на эту же дату", + "а что мы уже выяснили по этой позиции?" + ], + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Targeted AGENT replay for the recent compose/inventory fixes: company clarification, inventory root restore, selected-object provenance, purchase date/documents follow-ups, and protection against technical garbage in user-facing replies.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase4_inventory_answer_shape_continuity.json", + "scenario_id": "address_truth_harness_phase4_inventory_answer_shape_continuity", + "semantic_tags": [ + "company_clarification", + "company_selection", + "inventory_root", + "meta_memory", + "meta_scope" + ] + }, + "source_session_id": null, + "session": { + "session_id": null, + "mode": "agent_semantic_run", + "items": [ + { + "message_id": "agent-user-001", + "role": "user", + "text": "какие остатки на складе на март 2021", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-002", + "role": "user", + "text": "давай по Альтернативе Плюс", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-003", + "role": "user", + "text": "тогда покажи остатки на март 2021", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-004", + "role": "user", + "text": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-005", + "role": "user", + "text": "а по этой позиции когда была закупка?", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-006", + "role": "user", + "text": "покажи документы по этой позиции", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-007", + "role": "user", + "text": "покажи еще раз остатки на эту же дату", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-008", + "role": "user", + "text": "а что мы уже выяснили по этой позиции?", + "created_at": "2026-04-17T15:08:06+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + } + ], + "agent_run": true, + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Targeted AGENT replay for the recent compose/inventory fixes: company clarification, inventory root restore, selected-object provenance, purchase date/documents follow-ups, and protection against technical garbage in user-facing replies.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase4_inventory_answer_shape_continuity.json", + "scenario_id": "address_truth_harness_phase4_inventory_answer_shape_continuity", + "semantic_tags": [ + "company_clarification", + "company_selection", + "inventory_root", + "meta_memory", + "meta_scope" + ] + } + } +} diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260416182626_gen-mo1t93wq-jy0453e.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260416182626_gen-mo1t93wq-jy0453e.json index f272e6e..03df783 100644 --- a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260416182626_gen-mo1t93wq-jy0453e.json +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260416182626_gen-mo1t93wq-jy0453e.json @@ -2,7 +2,7 @@ "suite_id": "assistant_saved_session_gen-mo1t93wq-jy0453e", "suite_version": "0.1.0", "schema_version": "assistant_saved_session_suite_v0_1", - "generated_at": "2026-04-16T18:26:26.186Z", + "generated_at": "2026-04-17T15:22:18.956Z", "generation_id": "gen-mo1t93wq-jy0453e", "mode": "saved_user_sessions", "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", @@ -99,9 +99,6 @@ { "user_message": "остатки на март 2016" }, - { - "user_message": "это по общей базе уже нужен вывод не по чепурнову" - }, { "user_message": "хвосты покажи по счету 60 на август 2022" }, diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417150806_gen-ag04171508-760111.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417150806_gen-ag04171508-760111.json new file mode 100644 index 0000000..aaf1601 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417150806_gen-ag04171508-760111.json @@ -0,0 +1,49 @@ +{ + "suite_id": "assistant_saved_session_gen-ag04171508-760111", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_suite_v0_1", + "generated_at": "2026-04-17T15:08:06+00:00", + "generation_id": "gen-ag04171508-760111", + "mode": "saved_user_sessions", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "domain": "inventory_answer_shape_and_continuity", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "agent_saved_user_sessions", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "а по этой позиции когда была закупка?" + }, + { + "user_message": "покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а что мы уже выяснили по этой позиции?" + } + ] + } + ] +} diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-XDhN0VgANV.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-XDhN0VgANV.json new file mode 100644 index 0000000..5dcf8a6 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-XDhN0VgANV.json @@ -0,0 +1,45 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-XDhN0VgANV", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "AGENT replay for inventory clarification continuity and answer-shape cleanliness", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "давай по Альтернативе Плюс" + }, + { + "user_message": "тогда покажи остатки на март 2021" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "а по этой позиции когда была закупка?" + }, + { + "user_message": "покажи документы по этой позиции" + }, + { + "user_message": "покажи еще раз остатки на эту же дату" + }, + { + "user_message": "а что мы уже выяснили по этой позиции?" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-az4ZDEQptK.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-az4ZDEQptK.json new file mode 100644 index 0000000..ba904f0 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-az4ZDEQptK.json @@ -0,0 +1,114 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-az4ZDEQptK", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "приветик - че как там дела" + }, + { + "user_message": "расскажи что можешь интересного" + }, + { + "user_message": "кайф - что там на складе по остаткам?" + }, + { + "user_message": "а исторические остатки на другие даты умеешь?" + }, + { + "user_message": "давай на июль 2017" + }, + { + "user_message": "март 2016" + }, + { + "user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?" + }, + { + "user_message": "а кому продали?" + }, + { + "user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?" + }, + { + "user_message": "ндс можешь прикинуть на дату покупки рабочей станции?" + }, + { + "user_message": "а какой ндс мы должны сгрузить на март 2020?" + }, + { + "user_message": "прикинь какой ндс нам надо заплатить на февраль 2017" + }, + { + "user_message": "кто у нас самый доходный клиент за все время" + }, + { + "user_message": "кто нам должен денег на май 2017" + }, + { + "user_message": "а какой ндс мы должны примерно заплатить за этот период?" + }, + { + "user_message": "мы должны комуто денег на сегодня?" + }, + { + "user_message": "а нам?" + }, + { + "user_message": "какой у нас самый доходный год" + }, + { + "user_message": "а за 2017 мы скок заработали?" + }, + { + "user_message": "сколько вообще денег мы заработали за все время?" + }, + { + "user_message": "ты умеешь считать дельту по договорам?" + }, + { + "user_message": "по чепурнову покажи все доки" + }, + { + "user_message": "а по свк" + }, + { + "user_message": "а сейчас у нас есть что на складе?" + }, + { + "user_message": "что нам отгружать чепурнов? какой товар или услугу?" + }, + { + "user_message": "какие остатки на складе на сегодня" + }, + { + "user_message": "остатки на март 2016" + }, + { + "user_message": "это по общей базе уже нужен вывод не по чепурнову" + }, + { + "user_message": "хвосты покажи по счету 60 на август 2022" + }, + { + "user_message": "Есть ли остатки товара, которые закупались очень давно" + }, + { + "user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020" + } + ] + } + ] +} \ No newline at end of file