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 61cdb53..b756332 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,9 +25,9 @@ This snapshot is based on: Latest graph rebuild: -- `5261 nodes` -- `11347 edges` -- `134 communities` +- `5284 nodes` +- `11402 edges` +- `138 communities` Most relevant current god nodes for turnaround `11`: @@ -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: `~90%` +Estimated overall turnaround completion: `~91%` ### Phase 0. Shared Baseline @@ -163,12 +163,13 @@ Remaining debt: ### Phase 3. Capability Contracts -Status: `86%` +Status: `88%` Reason: - critical inventory/address capabilities are materially contract-driven; - selected-object and root capability behavior is much more explicit than before. +- inventory intent-family now has an explicit owner in `addressInventoryIntentSignals.ts` instead of staying only as inline signal pressure inside `resolveAddressIntent()`. Remaining debt: @@ -251,6 +252,7 @@ Compared with the pre-turnaround baseline, the system is now materially better i - 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; +- inventory intent classification now has a dedicated owner instead of being only an inline segment inside the central address intent resolver; - 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 diff --git a/docs/ARCH/11 - architecture_turnaround/09 - pre_expansion_cut_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/09 - pre_expansion_cut_2026-04-17.md new file mode 100644 index 0000000..8c26d68 --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/09 - pre_expansion_cut_2026-04-17.md @@ -0,0 +1,128 @@ +# 09 - Pre-Expansion Cut (2026-04-17) + +## Purpose + +This note freezes the practical cutoff for turnaround `11` before mass domain expansion. + +The goal is not architectural perfection. + +The goal is to separate: + +- what must be closed before large-scale domain growth; +- what can be consciously deferred without putting expansion quality at risk. + +## Current Read + +As of `2026-04-17`, the architecture is no longer in the "foundations are unstable" state. + +The system already has: + +- extracted route / transition / boundary / meta / memory owners; +- explicit truth and coverage/evidence contracts; +- scenario acceptance artifacts; +- live AGENT semantic replay practice; +- materially stronger selected-object and temporal continuity than in the baseline state. + +The remaining problem is different now: + +- quality risk is concentrated in a small number of central pressure points; +- these pressure points will amplify regressions once many new domains are added. + +## Must Close Before Mass Domain Expansion + +### 1. Intent concentration in `resolveAddressIntent()` + +Why it matters: + +- this is still the main domain-intent concentration point in the graph; +- new domain slices will increase route collisions and accidental cross-triggering; +- follow-up-heavy contours will become harder to reason about if raw signal families stay mixed in one resolver body. + +What "done enough" means: + +- the most stressed signal families are delegated to dedicated owners; +- inventory, counterparty/documents, and high-risk settlement families are no longer all encoded inline in one body; +- targeted regression packs exist for each extracted family. + +Current status: + +- inventory signal-family is now delegated to `addressInventoryIntentSignals.ts`; +- this reduces ownership pressure, even though the old inline bodies still remain as cleanup debt. + +### 2. Answer semantics pressure in `composeFactualReplyBody()` + +Why it matters: + +- this still controls too much user-facing behavior; +- technical leakage, limitation phrasing, and answer-shape instability can spread into new domains quickly; +- every new domain added on top of a still-heavy compose body increases presentation inconsistency risk. + +What "done enough" means: + +- the hottest answer families are routed through dedicated builders/presentation owners; +- blocked / limited / humanized fallback semantics are explicit for the most important contours; +- user-facing replies no longer expose internal route/capability/debug jargon on critical business paths. + +### 3. Business-first quality guard on hot contours + +Why it matters: + +- mass expansion will multiply edge cases faster than humans can manually spot them; +- if hot contours still leak technical junk or weak follow-up logic, the problem will scale with every new domain. + +What "done enough" means: + +- AGENT semantic runs continue to validate mixed business chains; +- core hot contours are checked for direct-answer usefulness, selected-object continuity, temporal honesty, and no technical leakage; +- enablement gaps are treated as contour-extension work, not dismissed as "unsupported". + +## Can Be Deferred After Expansion Starts + +### 1. Full `assistantService.ts` beautification + +This still matters, but it is no longer the primary pre-expansion blocker. + +As long as runtime-critical policy ownership is already externalized, some coordinator-local legacy bodies can remain temporarily. + +### 2. Full elimination of every residual helper duplicate + +If ownership has already moved and regression coverage exists, residual historical helper bodies are cleanup work, not expansion blockers. + +They should be removed during later hardening passes, but they do not all need to be gone before domain growth begins. + +### 3. Long-tail micro-polish in reply tone + +Minor phrasing improvements can be postponed if: + +- the business answer is already truthful; +- no technical internals leak to the user; +- answer shape remains useful and stable. + +### 4. UI-first acceptance ergonomics + +The current script-driven acceptance loop is good enough for pre-expansion gating. + +Promoting every replay step into a more polished UI loop can happen later. + +## Recommended Final Turnaround Sequence + +### Pass 1 + +- continue extracting the highest-risk signal families out of `resolveAddressIntent()`; +- keep business behavior stable through focused regression packs; +- treat this as the main pre-expansion hardening track. + +### Pass 2 + +- reduce remaining answer-semantics pressure in `composeFactualReplyBody()`; +- harden blocked / limited / humanized response semantics on the hottest business contours; +- confirm with AGENT replay that user-facing answers stay business-first. + +## Practical Exit Condition + +Turnaround `11` can be considered "ready for domain expansion" when: + +- the main route-collision pressure in `resolveAddressIntent()` is materially reduced; +- the hottest user-facing answer families are protected from technical leakage; +- AGENT replay confirms stable business usefulness on the core mixed chains; +- remaining debt is mostly cleanup debt, not architecture debt that can multiply regressions during expansion. diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 453151e..aa57fcd 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveAddressIntent = resolveAddressIntent; +const addressInventoryIntentSignals_1 = require("./addressInventoryIntentSignals"); const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers"); const RECEIVABLES_STRONG = [ "кто должен нам", @@ -1544,6 +1545,10 @@ function resolveAddressIntent(userMessage) { reasons: ["documents_by_account_drilldown_signal_detected"] }; } + const inventoryIntent = (0, addressInventoryIntentSignals_1.resolveInventoryAddressIntent)(text); + if (inventoryIntent) { + return inventoryIntent; + } if (/(?:старым\s+закупк(?:ам|и|ах)|относится\s+ли\s+.*\s+к\s+старым\s+закупк(?:ам|и|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test(text)) { return { intent: "inventory_aging_by_purchase_date", diff --git a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js new file mode 100644 index 0000000..5f5117e --- /dev/null +++ b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js @@ -0,0 +1,232 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resolveInventoryAddressIntent = resolveInventoryAddressIntent; +const inventoryLifecycleCueHelpers_1 = require("./inventoryLifecycleCueHelpers"); +function hasInventoryAccount41Anchor(text) { + return /(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)/iu.test(text); +} +function hasInventoryAsOfCue(text) { + return /(?:сейчас|текущ|РЅР°\s+дату|РїРѕ\s+состоянию|срез|РЅР°\s+конец|date|as\s+of|current|now|today)/iu.test(text); +} +function hasInventoryOnHandSignal(text) { + const hasColloquialStockSnapshotCue = /(?:что|С‡[еёо])\s+(?:Сѓ\s+нас\s+)?РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)(?=$|[\s,.;:!?])/iu.test(text); + const hasStockStateCue = /(?:(?:что|С‡[еёо])\s+там\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|(?:что|С‡[еёо]).*РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее).*(?:РЅР°\s+)?склад(?:Рµ|Сѓ|РѕРј|ах)|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|ситуац(?:РёСЏ|РёРё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|обстановк(?:Р°|Рё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test(text); + const hasAccount41Anchor = hasInventoryAccount41Anchor(text); + const hasStockLexeme = /(?:склад(?:Рµ|Сѓ|РѕРј|С‹|РѕРІ)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); + if (!hasStockLexeme && !hasAccount41Anchor) { + return false; + } + if (hasInventoryProvenanceSignalV2(text) || + hasInventoryPurchaseDocumentsSignalV2(text) || + hasInventorySaleTraceSignalV2(text) || + hasInventoryAgingSignal(text) || + hasInventoryPurchaseToSaleChainSignal(text)) { + return false; + } + const hasGoodsLexeme = /(?:товар(?:С‹|РѕРІ|РѕРј|Р°|ные)?|номенклатур|материал(?:С‹|РѕРІ|Р°|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text); + const hasBalanceLexeme = /(?:леж(?:РёС‚|ат)|есть|числ(?:РёС‚(?:СЃСЏ|СЃСЊ)|ятся)|остат(?:РѕРє|РєРё)|срез|РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)|ситуац(?:РёСЏ|РёРё)|обстановк(?:Р°|Рё)|today|now|current|as\s+of)/iu.test(text); + const hasRequestCue = /(?:покажи|показать|выведи|дай|какие|что|С‡[еёо]|какой|сколько|проверь|проверить|чекни|check|show|list|which|what)/iu.test(text); + if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) { + return true; + } + return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) && + (hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue); +} +function hasSelectedObjectInventoryCue(text) { + return /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+выбранной\s+позиции|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|РїРѕ\s+нему|РїРѕ\s+ней|РїРѕ\s+РЅРёРј|РїРѕ\s+нему\s+Р¶Рµ|РїРѕ\s+ней\s+Р¶Рµ|selected\s+object)/iu.test(String(text ?? "")); +} +function hasSelectedObjectInventoryProvenanceSignal(text) { + return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text); +} +function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { + const hasPurchaseDocumentsCue = /(?:РїРѕ\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|РїРѕ\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text) || + /(?:(?:РїРѕ\s+каким|какими)\s+РґРѕРє[Р°-СЏС‘]*[\s\S]{0,80}(?:РєСѓРїРёР»|куплен)|РґРѕРє(?:Рё|умент[Р°-СЏС‘]*)[\s\S]{0,80}(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}РґРѕРє(?:Рё|умент[Р°-СЏС‘]*))/iu.test(text); + return hasSelectedObjectInventoryCue(text) && hasPurchaseDocumentsCue; +} +function hasSelectedObjectInventorySaleTraceSignal(text) { + return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventorySaleCue)(text); +} +function hasSelectedObjectInventoryProfitabilitySignal(text) { + return hasSelectedObjectInventoryCue(text) && (0, inventoryLifecycleCueHelpers_1.hasInventoryProfitabilityCue)(text); +} +function hasInventoryProvenanceSignalV2(text) { + const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:РѕРє|РєРё)|склад)/iu.test(text); + const hasSupplierCue = (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(text) || /кем\s+поставлен/iu.test(text); + const hasPurchaseCue = /(?:куплен(?:С‹|Р°|Рѕ)?|закупк|происхождени|откуда|РіРґРµ\s+(?:РјС‹\s+)?купили(?:\s+(?:это|его|товар|позицию))?|РіРґРµ\s+куплено|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:С‹|Р°)?|purchase\s+provenance|purchase\s+date)/iu.test(text) || (0, inventoryLifecycleCueHelpers_1.hasInventoryPurchaseStem)(text); + return hasItemCue && hasSupplierCue && hasPurchaseCue; +} +function hasInventoryPurchaseDateSignal(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) { + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasPurchaseDocCue = /(?:РїРѕ\s+каким\s+документам\s+был\s+куплен|РїРѕ\s+каким\s+документам\s+куплен|какими\s+документами\s+был\s+куплен|документ(?:ам|С‹)\s+закупк|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text); + return hasItemCue && hasPurchaseDocCue; +} +function hasInventorySaleTraceSignalV2(text) { + const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text); + const hasTraceCue = /(?:РєРѕРјСѓ\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали|РєРѕРјСѓ\s+был\s+продан|РєСѓРґР°\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали(?:\s+(?:это|его|товар|позицию))?|РєСѓРґР°\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+РєСѓРїРёР»|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+РїСЂРѕС€[её]Р»\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test(text); + return hasItemCue && hasTraceCue; +} +function hasInventorySupplierStockOverlapSignal(text) { + const hasDirectSingleItemSupplierQuestion = /(?:РѕС‚\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё))|РѕС‚\s+РєРѕРіРѕ\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё)))/iu.test(text); + if (hasDirectSingleItemSupplierQuestion) { + return false; + } + const hasSupplierCue = /(?:поставщик|supplier|vendor|РѕС‚\s+поставщика|Сѓ\s+поставщика)/iu.test(text); + const hasStockCue = /(?:склад|остат(?:РѕРє|РєРµ|РєРѕРІ)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|РЅР°\s+дату|РїРѕ\s+состоянию\s+РЅР°\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test(text); + return hasSupplierCue && hasStockCue; +} +function hasInventoryAgingSignal(text) { + const hasResidueCue = /(?:остат(?:РѕРє|РєРё)|РІ\s+остатке|среди\s+текущих\s+остатков|РЅР°\s+складе|stock\s+residue|stock\s+balance)/iu.test(text); + const hasAgingCue = /(?:стар(?:ые|ым|ых)\s+закупк|стары(?:Рј|С…)\s+закупк(?:ам|Рё|ах)|относит(?:СЃСЏ|СЃСЏ\s+ли)?\s+.*\s+Рє\s+старым\s+закупк|закупал(?:РёСЃСЊ|СЃСЏ)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+РґРѕ(?:\s+даты)?|закуплен(?:С‹|Р°)?\s+давно|приобретен\s+давно|задолго\s+РґРѕ(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test(text); + return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+РґРѕ)/iu.test(text)); +} +function hasInventoryPurchaseToSaleChainSignal(text) { + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasChainCue = /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test(text) || text.includes("->"); + return hasItemCue && hasChainCue; +} +function hasInventorySupplierToBuyerChainSignal(text) { + const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text); + const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasChainCue = /(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+Рє\s+покупател|поставщик\s+Рє\s+клиент|поставщик\s+Рє\s+товару\s+Рё\s+покупателю)/iu.test(text) || text.includes("->"); + return hasSupplierCue && hasBuyerCue && hasItemCue && hasChainCue; +} +function resolveInventoryAddressIntent(text) { + if (/(?:старым\s+закупк(?:ам|Рё|ах)|относится\s+ли\s+.*\s+Рє\s+старым\s+закупк(?:ам|Рё|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test(text)) { + return { + intent: "inventory_aging_by_purchase_date", + confidence: "high", + reasons: ["inventory_aging_signal_detected_strong"] + }; + } + if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "high", + reasons: ["inventory_account_41_as_of_date_signal_detected"] + }; + } + if (/(?:без\s+понятн(?:РѕР№|РѕРіРѕ)\s+РїСЂРёРІСЏР·Рє(?:Рё|Р°)\s+Рє\s+поставщик|без\s+РїСЂРёРІСЏР·Рє(?:Рё|Р°)\s+Рє\s+поставщик|unresolved\s+supplier\s+link)/iu.test(text)) { + return { + intent: "inventory_supplier_stock_overlap_as_of_date", + confidence: "medium", + reasons: ["inventory_unresolved_provenance_signal_detected"] + }; + } + if (hasInventorySupplierStockOverlapSignal(text)) { + return { + intent: "inventory_supplier_stock_overlap_as_of_date", + confidence: "medium", + reasons: ["inventory_supplier_stock_overlap_signal_detected"] + }; + } + if (/(?:supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|поставщик\s+Рє\s+покупателю|поставщик\s*->\s*товар\s*->\s*покупател|документально\s+подтвержденн\w+\s+цепочк)/iu.test(text) && + /(?:поставщик|supplier|vendor)/iu.test(text) && + /(?:покупател|buyer|customer|client)/iu.test(text) && + /(?:товар|номенклатур|sku|item|product)/iu.test(text)) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "high", + reasons: ["inventory_supplier_to_buyer_chain_signal_detected_strong"] + }; + } + if (hasInventoryPurchaseToSaleChainSignal(text)) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "medium", + reasons: ["inventory_purchase_to_sale_chain_signal_detected"] + }; + } + if (hasInventoryAgingSignal(text)) { + return { + intent: "inventory_aging_by_purchase_date", + confidence: "medium", + reasons: ["inventory_aging_signal_detected"] + }; + } + if (hasSelectedObjectInventoryProvenanceSignal(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_provenance_signal_detected"] + }; + } + if (hasInventoryProvenanceSignalV2(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_provenance_signal_detected"] + }; + } + if (hasInventoryPurchaseDateSignal(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_purchase_date_signal_detected"] + }; + } + if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) { + return { + intent: "inventory_purchase_documents_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_purchase_documents_signal_detected"] + }; + } + if (hasInventoryPurchaseDocumentsSignalV2(text)) { + return { + intent: "inventory_purchase_documents_for_item", + confidence: "medium", + reasons: ["inventory_purchase_documents_signal_detected"] + }; + } + if (hasSelectedObjectInventoryProfitabilitySignal(text)) { + return { + intent: "inventory_profitability_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_profitability_signal_detected"] + }; + } + if (hasSelectedObjectInventorySaleTraceSignal(text)) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_sale_trace_signal_detected"] + }; + } + if (/(?:РєРѕРјСѓ\s+(?:РјС‹\s+)?впарили(?:\s+(?:это|его|товар|позицию))?|РєРѕРјСѓ\s+РІ\s+итоге\s+РјС‹\s+впарили)/iu.test(text) && + /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text)) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_sale_trace_signal_detected"] + }; + } + if (hasInventorySaleTraceSignalV2(text)) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_sale_trace_signal_detected"] + }; + } + if (hasInventorySupplierToBuyerChainSignal(text)) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "medium", + reasons: ["inventory_supplier_to_buyer_chain_signal_detected"] + }; + } + if (hasInventoryOnHandSignal(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "high", + reasons: ["inventory_on_hand_signal_detected"] + }; + } + return null; +} diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index c07b9e1..69e04f2 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1,4 +1,5 @@ import type { AddressIntentResolution } from "../types/addressQuery"; +import { resolveInventoryAddressIntent } from "./addressInventoryIntentSignals"; import { hasInventoryProfitabilityCue, hasInventoryPurchaseStem, @@ -1900,6 +1901,11 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti }; } + const inventoryIntent = resolveInventoryAddressIntent(text); + if (inventoryIntent) { + return inventoryIntent; + } + if ( /(?:старым\s+закупк(?:ам|и|ах)|относится\s+ли\s+.*\s+к\s+старым\s+закупк(?:ам|и|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test( text diff --git a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts new file mode 100644 index 0000000..dad9275 --- /dev/null +++ b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts @@ -0,0 +1,334 @@ +import type { AddressIntentResolution } from "../types/addressQuery"; +import { + hasInventoryProfitabilityCue, + hasInventoryPurchaseStem, + hasInventorySaleCue, + hasInventorySupplierCue +} from "./inventoryLifecycleCueHelpers"; + +function hasInventoryAccount41Anchor(text: string): boolean { + return /(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)\D{0,12}41(?:[.,]0?1)?/iu.test(text) || /41(?:[.,]0?1)?\D{0,12}(?:СЃС‡[её]С‚(?:Р°|Рµ|Сѓ)?|счет(?:Р°|Рµ|Сѓ)?)/iu.test(text); +} + +function hasInventoryAsOfCue(text: string): boolean { + return /(?:сейчас|текущ|РЅР°\s+дату|РїРѕ\s+состоянию|срез|РЅР°\s+конец|date|as\s+of|current|now|today)/iu.test( + text + ); +} + +function hasInventoryOnHandSignal(text: string): boolean { + const hasColloquialStockSnapshotCue = /(?:что|С‡[еёо])\s+(?:Сѓ\s+нас\s+)?РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)(?=$|[\s,.;:!?])/iu.test( + text + ); + const hasStockStateCue = /(?:(?:что|С‡[еёо])\s+там\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|(?:что|С‡[еёо]).*РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее).*(?:РЅР°\s+)?склад(?:Рµ|Сѓ|РѕРј|ах)|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|ситуац(?:РёСЏ|РёРё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|обстановк(?:Р°|Рё)\s+РЅР°\s+склад(?:Рµ|Сѓ|РѕРј|ах)|what(?:'s| is)?\s+(?:there\s+)?(?:on|in)\s+(?:the\s+)?(?:warehouse|stock)|what(?:'s| is)?\s+happening\s+(?:on|in)\s+(?:the\s+)?(?:warehouse|stock))/iu.test( + text + ); + const hasAccount41Anchor = hasInventoryAccount41Anchor(text); + const hasStockLexeme = + /(?:склад(?:Рµ|Сѓ|РѕРј|С‹|РѕРІ)?|warehouse|stock(?:room)?|inventory|on[\s-]?hand)/iu.test(text); + if (!hasStockLexeme && !hasAccount41Anchor) { + return false; + } + if ( + hasInventoryProvenanceSignalV2(text) || + hasInventoryPurchaseDocumentsSignalV2(text) || + hasInventorySaleTraceSignalV2(text) || + hasInventoryAgingSignal(text) || + hasInventoryPurchaseToSaleChainSignal(text) + ) { + return false; + } + const hasGoodsLexeme = + /(?:товар(?:С‹|РѕРІ|РѕРј|Р°|ные)?|номенклатур|материал(?:С‹|РѕРІ|Р°|ам)?|item(?:s)?|sku|product(?:s)?)/iu.test(text); + const hasBalanceLexeme = + /(?:леж(?:РёС‚|ат)|есть|числ(?:РёС‚(?:СЃСЏ|СЃСЊ)|ятся)|остат(?:РѕРє|РєРё)|срез|РЅР°\s+дат|РїРѕ\s+состоянию|РЅР°\s+конец|РїСЂРѕРёСЃС…РѕРґ(?:РёС‚|ило|ящее)|ситуац(?:РёСЏ|РёРё)|обстановк(?:Р°|Рё)|today|now|current|as\s+of)/iu.test( + text + ); + const hasRequestCue = + /(?:покажи|показать|выведи|дай|какие|что|С‡[еёо]|какой|сколько|проверь|проверить|чекни|check|show|list|which|what)/iu.test( + text + ); + if (hasAccount41Anchor && (hasGoodsLexeme || hasBalanceLexeme || hasRequestCue || hasInventoryAsOfCue(text))) { + return true; + } + return (hasGoodsLexeme || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue) && + (hasRequestCue || hasBalanceLexeme || hasColloquialStockSnapshotCue || hasStockStateCue); +} + +function hasSelectedObjectInventoryCue(text: string): boolean { + return /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+выбранной\s+позиции|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|РїРѕ\s+нему|РїРѕ\s+ней|РїРѕ\s+РЅРёРј|РїРѕ\s+нему\s+Р¶Рµ|РїРѕ\s+ней\s+Р¶Рµ|selected\s+object)/iu.test( + String(text ?? "") + ); +} + +function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean { + return hasSelectedObjectInventoryCue(text) && hasInventorySupplierCue(text); +} + +function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolean { + const hasPurchaseDocumentsCue = + /(?:РїРѕ\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|РїРѕ\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( + text + ) || + /(?:(?:РїРѕ\s+каким|какими)\s+РґРѕРє[Р°-СЏС‘]*[\s\S]{0,80}(?:РєСѓРїРёР»|куплен)|РґРѕРє(?:Рё|умент[Р°-СЏС‘]*)[\s\S]{0,80}(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:РїРѕ\s+(?:РЅРёРј|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}РґРѕРє(?:Рё|умент[Р°-СЏС‘]*))/iu.test( + text + ); + return hasSelectedObjectInventoryCue(text) && hasPurchaseDocumentsCue; +} + +function hasSelectedObjectInventorySaleTraceSignal(text: string): boolean { + return hasSelectedObjectInventoryCue(text) && hasInventorySaleCue(text); +} + +function hasSelectedObjectInventoryProfitabilitySignal(text: string): boolean { + return hasSelectedObjectInventoryCue(text) && hasInventoryProfitabilityCue(text); +} + +function hasInventoryProvenanceSignalV2(text: string): boolean { + const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:РѕРє|РєРё)|склад)/iu.test(text); + const hasSupplierCue = hasInventorySupplierCue(text) || /кем\s+поставлен/iu.test(text); + const hasPurchaseCue = + /(?:куплен(?:С‹|Р°|Рѕ)?|закупк|происхождени|откуда|РіРґРµ\s+(?:РјС‹\s+)?купили(?:\s+(?:это|его|товар|позицию))?|РіРґРµ\s+куплено|РєРѕРіРґР°\s+был\s+куплен|РєРѕРіРґР°\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:С‹|Р°)?|purchase\s+provenance|purchase\s+date)/iu.test( + text + ) || hasInventoryPurchaseStem(text); + return hasItemCue && hasSupplierCue && hasPurchaseCue; +} + +function hasInventoryPurchaseDateSignal(text: string): boolean { + 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: string): boolean { + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasPurchaseDocCue = /(?:РїРѕ\s+каким\s+документам\s+был\s+куплен|РїРѕ\s+каким\s+документам\s+куплен|какими\s+документами\s+был\s+куплен|документ(?:ам|С‹)\s+закупк|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( + text + ); + return hasItemCue && hasPurchaseDocCue; +} + +function hasInventorySaleTraceSignalV2(text: string): boolean { + const hasItemCue = /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text); + const hasTraceCue = + /(?:РєРѕРјСѓ\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали|РєРѕРјСѓ\s+был\s+продан|РєСѓРґР°\s+(?:РІ\s+итоге\s+)?(?:РјС‹\s+)?продали(?:\s+(?:это|его|товар|позицию))?|РєСѓРґР°\s+(?:была\s+)?реализована\s+(?:позиция|номенклатура|продукция)|кто\s+РєСѓРїРёР»|buyer|sale\s+trace|trace\s+of\s+sale|через\s+какие\s+документы\s+РїСЂРѕС€[её]Р»\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*warehouse\s*->\s*sale|purchase\s*->\s*stock\s*->\s*sale)/iu.test( + text + ); + return hasItemCue && hasTraceCue; +} + +function hasInventorySupplierStockOverlapSignal(text: string): boolean { + const hasDirectSingleItemSupplierQuestion = + /(?:РѕС‚\s+какого\s+поставщика\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё))|РѕС‚\s+РєРѕРіРѕ\s+куплен\s+(?:товар|номенклатур(?:Р°|Сѓ|С‹)|позици(?:СЏ|СЋ|Рё)))/iu.test( + text + ); + if (hasDirectSingleItemSupplierQuestion) { + return false; + } + const hasSupplierCue = /(?:поставщик|supplier|vendor|РѕС‚\s+поставщика|Сѓ\s+поставщика)/iu.test(text); + const hasStockCue = /(?:склад|остат(?:РѕРє|РєРµ|РєРѕРІ)|лежат|лежит|сейчас\s+еще|сейчас\s+ещ[её]|РЅР°\s+дату|РїРѕ\s+состоянию\s+РЅР°\s+дату|current\s+stock|stock\s+overlap|что\s+сейчас\s+лежит)/iu.test( + text + ); + return hasSupplierCue && hasStockCue; +} + +function hasInventoryAgingSignal(text: string): boolean { + const hasResidueCue = + /(?:остат(?:РѕРє|РєРё)|РІ\s+остатке|среди\s+текущих\s+остатков|РЅР°\s+складе|stock\s+residue|stock\s+balance)/iu.test(text); + const hasAgingCue = + /(?:стар(?:ые|ым|ых)\s+закупк|стары(?:Рј|С…)\s+закупк(?:ам|Рё|ах)|относит(?:СЃСЏ|СЃСЏ\s+ли)?\s+.*\s+Рє\s+старым\s+закупк|закупал(?:РёСЃСЊ|СЃСЏ)\s+очень\s+давно|очень\s+давно|давно\s+куплен|давно\s+приобретен|куплен\s+задолго\s+РґРѕ(?:\s+даты)?|закуплен(?:С‹|Р°)?\s+давно|приобретен\s+давно|задолго\s+РґРѕ(?:\s+даты)?|возраст\s+остатк|возраст\s+закупк|aged?\s+stock|old\s+purchase|old\s+purchases|old\s+stock|bought\s+long\s+ago|purchased\s+long\s+ago|aging\s+by\s+purchase\s+date|very\s+old\s+stock|very\s+old\s+purchase|old\s+procurement|older\s+purchases|aged\s+items|old\s+goods)/iu.test( + text + ); + return hasAgingCue || (hasResidueCue && /(?:давно\s+куплен|давно\s+приобретен|задолго\s+РґРѕ)/iu.test(text)); +} + +function hasInventoryPurchaseToSaleChainSignal(text: string): boolean { + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasChainCue = + /(?:закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|закупка\s*->\s*склад\s*->\s*продажа|цепочк[аи]\s+движен|документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*(?:buyer|customer)|supplier\s+to\s+buyer|supplier\s+to\s+item\s+to\s+buyer)/iu.test( + text + ) || text.includes("->"); + return hasItemCue && hasChainCue; +} + +function hasInventorySupplierToBuyerChainSignal(text: string): boolean { + const hasSupplierCue = /(?:поставщик|supplier|vendor)/iu.test(text); + const hasBuyerCue = /(?:покупател|buyer|customer|client)/iu.test(text); + const hasItemCue = /(?:товар|номенклатур|sku|item|product)/iu.test(text); + const hasChainCue = + /(?:документально\s+подтвержденн\w+\s+цепочк|supplier\s*->\s*item\s*->\s*buyer|supplier\s*->\s*item\s*->\s*customer|supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|supplier\s+to\s+item\s+to\s+buyer|поставщик\s*->\s*товар\s*->\s*покупател|поставщик\s*->\s*товар\s*->\s*клиент|поставщик\s*->\s*товар\s*->\s*покупатель|поставщик\s+Рє\s+покупател|поставщик\s+Рє\s+клиент|поставщик\s+Рє\s+товару\s+Рё\s+покупателю)/iu.test( + text + ) || text.includes("->"); + return hasSupplierCue && hasBuyerCue && hasItemCue && hasChainCue; +} + +export function resolveInventoryAddressIntent(text: string): AddressIntentResolution | null { + if ( + /(?:старым\s+закупк(?:ам|Рё|ах)|относится\s+ли\s+.*\s+Рє\s+старым\s+закупк(?:ам|Рё|ах)|очень\s+давно|давно\s+куплен|давно\s+приобретен|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test( + text + ) + ) { + return { + intent: "inventory_aging_by_purchase_date", + confidence: "high", + reasons: ["inventory_aging_signal_detected_strong"] + }; + } + + if (hasInventoryAccount41Anchor(text) && hasInventoryAsOfCue(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "high", + reasons: ["inventory_account_41_as_of_date_signal_detected"] + }; + } + + if ( + /(?:без\s+понятн(?:РѕР№|РѕРіРѕ)\s+РїСЂРёРІСЏР·Рє(?:Рё|Р°)\s+Рє\s+поставщик|без\s+РїСЂРёРІСЏР·Рє(?:Рё|Р°)\s+Рє\s+поставщик|unresolved\s+supplier\s+link)/iu.test( + text + ) + ) { + return { + intent: "inventory_supplier_stock_overlap_as_of_date", + confidence: "medium", + reasons: ["inventory_unresolved_provenance_signal_detected"] + }; + } + + if (hasInventorySupplierStockOverlapSignal(text)) { + return { + intent: "inventory_supplier_stock_overlap_as_of_date", + confidence: "medium", + reasons: ["inventory_supplier_stock_overlap_signal_detected"] + }; + } + + if ( + /(?:supplier\s*->\s*buyer|supplier\s+to\s+buyer|supplier\s+to\s+buyer\s+chain|поставщик\s+Рє\s+покупателю|поставщик\s*->\s*товар\s*->\s*покупател|документально\s+подтвержденн\w+\s+цепочк)/iu.test( + text + ) && + /(?:поставщик|supplier|vendor)/iu.test(text) && + /(?:покупател|buyer|customer|client)/iu.test(text) && + /(?:товар|номенклатур|sku|item|product)/iu.test(text) + ) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "high", + reasons: ["inventory_supplier_to_buyer_chain_signal_detected_strong"] + }; + } + + if (hasInventoryPurchaseToSaleChainSignal(text)) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "medium", + reasons: ["inventory_purchase_to_sale_chain_signal_detected"] + }; + } + + if (hasInventoryAgingSignal(text)) { + return { + intent: "inventory_aging_by_purchase_date", + confidence: "medium", + reasons: ["inventory_aging_signal_detected"] + }; + } + + if (hasSelectedObjectInventoryProvenanceSignal(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_provenance_signal_detected"] + }; + } + + if (hasInventoryProvenanceSignalV2(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_provenance_signal_detected"] + }; + } + + if (hasInventoryPurchaseDateSignal(text)) { + return { + intent: "inventory_purchase_provenance_for_item", + confidence: "medium", + reasons: ["inventory_purchase_date_signal_detected"] + }; + } + + if (hasSelectedObjectInventoryPurchaseDocumentsSignal(text)) { + return { + intent: "inventory_purchase_documents_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_purchase_documents_signal_detected"] + }; + } + + if (hasInventoryPurchaseDocumentsSignalV2(text)) { + return { + intent: "inventory_purchase_documents_for_item", + confidence: "medium", + reasons: ["inventory_purchase_documents_signal_detected"] + }; + } + + if (hasSelectedObjectInventoryProfitabilitySignal(text)) { + return { + intent: "inventory_profitability_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_profitability_signal_detected"] + }; + } + + if (hasSelectedObjectInventorySaleTraceSignal(text)) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_selected_object_sale_trace_signal_detected"] + }; + } + + if ( + /(?:РєРѕРјСѓ\s+(?:РјС‹\s+)?впарили(?:\s+(?:это|его|товар|позицию))?|РєРѕРјСѓ\s+РІ\s+итоге\s+РјС‹\s+впарили)/iu.test(text) && + /(?:товар|номенклатур|sku|item|product|позици(?:СЏ|СЋ|Рё)|продукци(?:СЏ|СЋ|Рё))/iu.test(text) + ) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_sale_trace_signal_detected"] + }; + } + + if (hasInventorySaleTraceSignalV2(text)) { + return { + intent: "inventory_sale_trace_for_item", + confidence: "medium", + reasons: ["inventory_sale_trace_signal_detected"] + }; + } + + if (hasInventorySupplierToBuyerChainSignal(text)) { + return { + intent: "inventory_purchase_to_sale_chain", + confidence: "medium", + reasons: ["inventory_supplier_to_buyer_chain_signal_detected"] + }; + } + + if (hasInventoryOnHandSignal(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "high", + reasons: ["inventory_on_hand_signal_detected"] + }; + } + + return null; +} diff --git a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts new file mode 100644 index 0000000..0bcc3ac --- /dev/null +++ b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { resolveInventoryAddressIntent } from "../src/services/addressInventoryIntentSignals"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; + +describe("addressInventoryIntentSignals", () => { + it("classifies warehouse snapshot wording through the extracted inventory owner", () => { + const result = resolveInventoryAddressIntent("show inventory on hand as of 2020-03-15"); + + expect(result?.intent).toBe("inventory_on_hand_as_of_date"); + expect(result?.reasons).toContain("inventory_on_hand_signal_detected"); + }); + + it("classifies selected-object purchase provenance wording through the extracted inventory owner", () => { + const result = resolveInventoryAddressIntent("selected object supplier provenance"); + + expect(result?.intent).toBe("inventory_purchase_provenance_for_item"); + expect(result?.reasons).toContain("inventory_selected_object_provenance_signal_detected"); + }); + + it("keeps the main resolver behavior stable through inventory-owner delegation", () => { + const result = resolveAddressIntent("а по этой позиции когда была закупка?"); + + expect(result.intent).toBe("inventory_purchase_provenance_for_item"); + expect(result.reasons).toContain("inventory_purchase_date_signal_detected"); + }); + + it("does not steal non-inventory open-items wording into the inventory owner", () => { + const result = resolveInventoryAddressIntent("хвосты покажи по счету 60 на август 2022"); + + expect(result).toBeNull(); + }); +});