ОРРКЕСТРАЦИЯ - Оркестрация домена: ввести step-validator и починить исторический as_of_date в item-trace
This commit is contained in:
+20
-20
@@ -22,10 +22,10 @@ const YEAR_PERIOD_SHORT_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2})\s*(?:г(?:од|о
|
||||
const YEAR_PERIOD_SHORT_ORDINAL_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(\d{2})\s*(?:[-\s]?(?:й|ый|ой|th))(?:\s*(?:г(?:од|ода)?\.?|year|period|период))?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const YEAR_PERIOD_SHORT_BARE_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)\s*(\d{2})(?=$|[\s,.;:!?()\-])/iu;
|
||||
const YEAR_PERIOD_ANY_PATTERN = /(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})[.\/-](0?[1-9]|1[0-2])(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NAME_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*(20\d{2})[.\/-](0?[1-9]|1[0-2])(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NAME_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
|
||||
const DOC_SIGNAL_PATTERN = "(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)";
|
||||
const COUNTERPARTY_TOKEN_NOISE = new Set([
|
||||
"за",
|
||||
@@ -249,7 +249,7 @@ function resolveMonthByName(rawMonthName) {
|
||||
return 3;
|
||||
if (/^апр|^april|^apr/.test(token))
|
||||
return 4;
|
||||
if (/^ма[йя]|^may/.test(token))
|
||||
if (/^ма(?:й|я|е)|^may/.test(token))
|
||||
return 5;
|
||||
if (/^июн|^june|^jun/.test(token))
|
||||
return 6;
|
||||
@@ -1062,6 +1062,18 @@ function usesAsOfPrimaryWindow(intent) {
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date");
|
||||
}
|
||||
function shouldDefaultAsOfDateToToday(intent) {
|
||||
return (intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
intent === "open_items_by_counterparty_or_contract" ||
|
||||
intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date");
|
||||
}
|
||||
function extractAddressFilters(userMessage, intent) {
|
||||
const rawText = String(userMessage ?? "").trim();
|
||||
const text = normalizeMojibakeString(rawText);
|
||||
@@ -1255,25 +1267,13 @@ function extractAddressFilters(userMessage, intent) {
|
||||
// For balance-style intents we force as_of_date deterministically:
|
||||
// - explicit as_of has priority;
|
||||
// - else use period_to boundary when provided;
|
||||
// - else default to today.
|
||||
if ((intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "inventory_purchase_provenance_for_item" ||
|
||||
intent === "inventory_purchase_documents_for_item" ||
|
||||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
intent === "inventory_sale_trace_for_item" ||
|
||||
intent === "inventory_purchase_to_sale_chain" ||
|
||||
intent === "inventory_aging_by_purchase_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date") &&
|
||||
!filters.as_of_date) {
|
||||
// - for strict snapshot intents only, default to today.
|
||||
if (usesAsOfPrimaryWindow(intent) && !filters.as_of_date) {
|
||||
if (filters.period_to) {
|
||||
filters.as_of_date = filters.period_to;
|
||||
warnings.push("as_of_date_derived_from_period_to");
|
||||
}
|
||||
else {
|
||||
else if (shouldDefaultAsOfDateToToday(intent)) {
|
||||
filters.as_of_date = new Date().toISOString().slice(0, 10);
|
||||
warnings.push("as_of_date_defaulted_today");
|
||||
}
|
||||
|
||||
@@ -1345,7 +1345,7 @@ function hasSelectedObjectInventoryCue(text) {
|
||||
}
|
||||
function hasSelectedObjectInventoryProvenanceSignal(text) {
|
||||
return (hasSelectedObjectInventoryCue(text) &&
|
||||
/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|supplier|vendor|поставщик)/iu.test(text));
|
||||
/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(text));
|
||||
}
|
||||
function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) {
|
||||
return (hasSelectedObjectInventoryCue(text) &&
|
||||
@@ -1353,8 +1353,8 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) {
|
||||
}
|
||||
function hasInventoryProvenanceSignalV2(text) {
|
||||
const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text);
|
||||
const hasSupplierCue = /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(text);
|
||||
const hasPurchaseCue = /(?:куплен(?:ы|а)?|закупк|происхождени|откуда|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(text);
|
||||
const hasSupplierCue = /(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/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);
|
||||
return hasItemCue && hasSupplierCue && hasPurchaseCue;
|
||||
}
|
||||
function hasInventoryPurchaseDateSignal(text) {
|
||||
|
||||
@@ -265,6 +265,12 @@ function hasAddressFollowupSignal(text) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function hasSelectedObjectInventoryFollowupSignal(text) {
|
||||
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return /(?:у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(text);
|
||||
}
|
||||
function hasDocsOrBankSignal(text) {
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(text);
|
||||
}
|
||||
@@ -377,6 +383,7 @@ function detectAddressQuestionMode(userMessage) {
|
||||
const hasManagementSignal = hasManagementProfileSignal(text);
|
||||
const hasLooseByAnchor = hasLooseByAnchorMention(text);
|
||||
const hasFollowupSignal = hasAddressFollowupSignal(text);
|
||||
const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text);
|
||||
const hasAccountCode = hasAccountCodeAnchor(text);
|
||||
if (hasAddressAction && (hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
|
||||
return {
|
||||
@@ -385,6 +392,13 @@ function detectAddressQuestionMode(userMessage) {
|
||||
reasons: ["address_action_detected", "address_entity_detected"]
|
||||
};
|
||||
}
|
||||
if (hasSelectedObjectInventoryFollowup && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
confidence: "medium",
|
||||
reasons: ["selected_object_inventory_followup_detected"]
|
||||
};
|
||||
}
|
||||
if (hasManagementSignal && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
|
||||
@@ -257,7 +257,7 @@ function hasSelectedObjectInventorySignal(text) {
|
||||
return /(?:по\s+выбранному\s+объекту|for\s+selected\s+object)/iu.test(String(text ?? ""));
|
||||
}
|
||||
function hasInventorySupplierFollowupCue(text) {
|
||||
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|supplier|vendor|поставщик)/iu.test(String(text ?? ""));
|
||||
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(String(text ?? ""));
|
||||
}
|
||||
function hasInventoryPurchaseDocumentsFollowupCue(text) {
|
||||
return /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\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(String(text ?? ""));
|
||||
@@ -674,6 +674,8 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
|
||||
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal);
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent) {
|
||||
return {
|
||||
|
||||
@@ -3278,6 +3278,12 @@ function hasPredecomposeExplicitDrilldownSignal(text) {
|
||||
const source = String(text ?? "");
|
||||
return ADDRESS_DOCS_SIGNAL_PATTERN.test(source) || ADDRESS_BANK_SIGNAL_PATTERN.test(source) || ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
|
||||
}
|
||||
function hasSelectedObjectInventoryFollowupSignalForPredecompose(text) {
|
||||
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? ""));
|
||||
}
|
||||
function isInventorySelectedObjectFollowupIntent(intent) {
|
||||
return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item";
|
||||
}
|
||||
function hasSameDateAccountFollowupSignalForPredecompose(text) {
|
||||
const source = String(text ?? "");
|
||||
const hasSameDate = /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu.test(source);
|
||||
@@ -3453,6 +3459,26 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
|
||||
sanitizedUserMessage
|
||||
}, userMessage);
|
||||
}
|
||||
const sourceHasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignalForPredecompose(repairedSourceMessage || userMessage);
|
||||
const candidateHasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignalForPredecompose(candidate);
|
||||
const candidateInjectsGenericDocsIntent = candidateIntentResolution.intent === "list_documents_by_counterparty" ||
|
||||
candidateIntentResolution.intent === "list_documents_by_contract";
|
||||
if (sourceHasSelectedObjectInventoryFollowup &&
|
||||
isInventorySelectedObjectFollowupIntent(sourceIntentResolution.intent) &&
|
||||
!candidateHasSelectedObjectInventoryFollowup &&
|
||||
candidateInjectsGenericDocsIntent) {
|
||||
return attachAddressPredecomposeContract({
|
||||
...baseMeta,
|
||||
attempted: true,
|
||||
applied: false,
|
||||
traceId: normalized?.trace_id ?? null,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
effectiveMessage: userMessage,
|
||||
reason: "normalized_fragment_rejected_selected_object_context_loss",
|
||||
fallbackRuleHit: null,
|
||||
sanitizedUserMessage
|
||||
}, userMessage);
|
||||
}
|
||||
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
|
||||
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
|
||||
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
|
||||
|
||||
@@ -29,13 +29,13 @@ const YEAR_PERIOD_SHORT_BARE_PATTERN =
|
||||
const YEAR_PERIOD_ANY_PATTERN =
|
||||
/(?:^|[\s,.;:!?()\-])((?:19|20)\d{2})(?!\s*(?:[-‐‑‒–—―−]|до|to|по)\s*(?:19|20)\d{2})(?![.\/-]\d)(?:\s*(?:г(?:од|ода)?\.?|year))?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_MONTH_YEAR_PATTERN =
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})(?=$|[\s,.;:!?()\-])/iu;
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*(0?[1-9]|1[0-2])[.\/-](20\d{2})(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN =
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})[.\/-](0?[1-9]|1[0-2])(?=$|[\s,.;:!?()\-])/iu;
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*(20\d{2})[.\/-](0?[1-9]|1[0-2])(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NAME_PATTERN =
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu;
|
||||
const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN =
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
|
||||
/(?:^|[\s,.;:!?()\-])(?:за|for|на|в|во|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu;
|
||||
const DOC_SIGNAL_PATTERN =
|
||||
"(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)";
|
||||
const COUNTERPARTY_TOKEN_NOISE = new Set([
|
||||
@@ -282,7 +282,7 @@ function resolveMonthByName(rawMonthName: string): number | undefined {
|
||||
if (/^фев|^february|^feb/.test(token)) return 2;
|
||||
if (/^мар|^march|^mar/.test(token)) return 3;
|
||||
if (/^апр|^april|^apr/.test(token)) return 4;
|
||||
if (/^ма[йя]|^may/.test(token)) return 5;
|
||||
if (/^ма(?:й|я|е)|^may/.test(token)) return 5;
|
||||
if (/^июн|^june|^jun/.test(token)) return 6;
|
||||
if (/^июл|^july|^jul/.test(token)) return 7;
|
||||
if (/^авг|^august|^aug/.test(token)) return 8;
|
||||
@@ -1211,6 +1211,8 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
|
||||
|
||||
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
|
||||
return (
|
||||
intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "inventory_purchase_provenance_for_item" ||
|
||||
intent === "inventory_purchase_documents_for_item" ||
|
||||
@@ -1227,6 +1229,21 @@ function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldDefaultAsOfDateToToday(intent: AddressIntent): boolean {
|
||||
return (
|
||||
intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
intent === "open_items_by_counterparty_or_contract" ||
|
||||
intent === "list_open_contracts" ||
|
||||
intent === "open_contracts_confirmed_as_of_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date"
|
||||
);
|
||||
}
|
||||
|
||||
export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction {
|
||||
const rawText = String(userMessage ?? "").trim();
|
||||
const text = normalizeMojibakeString(rawText);
|
||||
@@ -1446,26 +1463,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
// For balance-style intents we force as_of_date deterministically:
|
||||
// - explicit as_of has priority;
|
||||
// - else use period_to boundary when provided;
|
||||
// - else default to today.
|
||||
if (
|
||||
(intent === "account_balance_snapshot" ||
|
||||
intent === "documents_forming_balance" ||
|
||||
intent === "inventory_on_hand_as_of_date" ||
|
||||
intent === "inventory_purchase_provenance_for_item" ||
|
||||
intent === "inventory_purchase_documents_for_item" ||
|
||||
intent === "inventory_supplier_stock_overlap_as_of_date" ||
|
||||
intent === "inventory_sale_trace_for_item" ||
|
||||
intent === "inventory_purchase_to_sale_chain" ||
|
||||
intent === "inventory_aging_by_purchase_date" ||
|
||||
intent === "payables_confirmed_as_of_date" ||
|
||||
intent === "receivables_confirmed_as_of_date" ||
|
||||
intent === "vat_payable_confirmed_as_of_date") &&
|
||||
!filters.as_of_date
|
||||
) {
|
||||
// - for strict snapshot intents only, default to today.
|
||||
if (usesAsOfPrimaryWindow(intent) && !filters.as_of_date) {
|
||||
if (filters.period_to) {
|
||||
filters.as_of_date = filters.period_to;
|
||||
warnings.push("as_of_date_derived_from_period_to");
|
||||
} else {
|
||||
} else if (shouldDefaultAsOfDateToToday(intent)) {
|
||||
filters.as_of_date = new Date().toISOString().slice(0, 10);
|
||||
warnings.push("as_of_date_defaulted_today");
|
||||
}
|
||||
|
||||
@@ -1612,7 +1612,7 @@ function hasSelectedObjectInventoryCue(text: string): boolean {
|
||||
function hasSelectedObjectInventoryProvenanceSignal(text: string): boolean {
|
||||
return (
|
||||
hasSelectedObjectInventoryCue(text) &&
|
||||
/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|supplier|vendor|поставщик)/iu.test(
|
||||
/(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(
|
||||
text
|
||||
)
|
||||
);
|
||||
@@ -1630,11 +1630,11 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea
|
||||
function hasInventoryProvenanceSignalV2(text: string): boolean {
|
||||
const hasItemCue = /(?:товар|номенклатур|sku|item|product|остат(?:ок|ки)|склад)/iu.test(text);
|
||||
const hasSupplierCue =
|
||||
/(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(
|
||||
/(?:от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставщик|supplier|vendor)/iu.test(
|
||||
text
|
||||
);
|
||||
const hasPurchaseCue =
|
||||
/(?:куплен(?:ы|а)?|закупк|происхождени|откуда|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(
|
||||
/(?:куплен(?:ы|а|о)?|закупк|происхождени|откуда|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|когда\s+был\s+куплен|когда\s+куплен|дата\s+закупк|кто\s+(?:нам\s+)?поставил|кем\s+поставлен|поставлен(?:ы|а)?|purchase\s+provenance|purchase\s+date)/iu.test(
|
||||
text
|
||||
);
|
||||
return hasItemCue && hasSupplierCue && hasPurchaseCue;
|
||||
|
||||
@@ -274,6 +274,15 @@ function hasAddressFollowupSignal(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSelectedObjectInventoryFollowupSignal(text: string): boolean {
|
||||
if (!/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)/iu.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return /(?:у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|товар|позицию))?|где\s+куплено|кто\s+(?:поставил|продал)|кому\s+(?:продали|реализовали)|когда\s+(?:примерно\s+)?купили|по\s+каким\s+документам\s+.*купили)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function hasDocsOrBankSignal(text: string): boolean {
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||
text
|
||||
@@ -393,6 +402,7 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
const hasManagementSignal = hasManagementProfileSignal(text);
|
||||
const hasLooseByAnchor = hasLooseByAnchorMention(text);
|
||||
const hasFollowupSignal = hasAddressFollowupSignal(text);
|
||||
const hasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignal(text);
|
||||
const hasAccountCode = hasAccountCodeAnchor(text);
|
||||
|
||||
if (hasAddressAction && (hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
|
||||
@@ -403,6 +413,14 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
};
|
||||
}
|
||||
|
||||
if (hasSelectedObjectInventoryFollowup && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
confidence: "medium",
|
||||
reasons: ["selected_object_inventory_followup_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasManagementSignal && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
|
||||
@@ -323,7 +323,7 @@ function hasSelectedObjectInventorySignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasInventorySupplierFollowupCue(text: string): boolean {
|
||||
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|supplier|vendor|поставщик)/iu.test(
|
||||
return /(?:кто\s+(?:(?:это|этот\s+товар|эту\s+позицию)\s+)?(?:нам\s+)?поставил|кто\s+(?:нам\s+)?поставил\s+(?:это|этот\s+товар|эту\s+позицию)|от\s+какого\s+поставщика|у\s+какого\s+поставщика|от\s+кого\s+куплен|у\s+кого\s+купили|где\s+(?:мы\s+)?купили(?:\s+(?:это|его|этот\s+товар|эту\s+позицию))?|где\s+куплено|supplier|vendor|поставщик)/iu.test(
|
||||
String(text ?? "")
|
||||
);
|
||||
}
|
||||
@@ -840,6 +840,8 @@ function deriveIntentWithFollowupContext(
|
||||
if (inventorySelectedObjectFollowup && hasInventorySupplierFollowupCue(normalizedMessage)) {
|
||||
if (
|
||||
detectedIntent.intent === "unknown" ||
|
||||
detectedIntent.intent === "list_documents_by_counterparty" ||
|
||||
detectedIntent.intent === "list_documents_by_contract" ||
|
||||
detectedIntent.intent === "inventory_on_hand_as_of_date" ||
|
||||
detectedIntent.intent === previousIntent
|
||||
) {
|
||||
|
||||
@@ -3235,6 +3235,12 @@ function hasPredecomposeExplicitDrilldownSignal(text) {
|
||||
const source = String(text ?? "");
|
||||
return ADDRESS_DOCS_SIGNAL_PATTERN.test(source) || ADDRESS_BANK_SIGNAL_PATTERN.test(source) || ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
|
||||
}
|
||||
function hasSelectedObjectInventoryFollowupSignalForPredecompose(text) {
|
||||
return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? ""));
|
||||
}
|
||||
function isInventorySelectedObjectFollowupIntent(intent) {
|
||||
return intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item";
|
||||
}
|
||||
function hasSameDateAccountFollowupSignalForPredecompose(text) {
|
||||
const source = String(text ?? "");
|
||||
const hasSameDate = /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu.test(source);
|
||||
@@ -3410,6 +3416,26 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
|
||||
sanitizedUserMessage
|
||||
}, userMessage);
|
||||
}
|
||||
const sourceHasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignalForPredecompose(repairedSourceMessage || userMessage);
|
||||
const candidateHasSelectedObjectInventoryFollowup = hasSelectedObjectInventoryFollowupSignalForPredecompose(candidate);
|
||||
const candidateInjectsGenericDocsIntent = candidateIntentResolution.intent === "list_documents_by_counterparty" ||
|
||||
candidateIntentResolution.intent === "list_documents_by_contract";
|
||||
if (sourceHasSelectedObjectInventoryFollowup &&
|
||||
isInventorySelectedObjectFollowupIntent(sourceIntentResolution.intent) &&
|
||||
!candidateHasSelectedObjectInventoryFollowup &&
|
||||
candidateInjectsGenericDocsIntent) {
|
||||
return attachAddressPredecomposeContract({
|
||||
...baseMeta,
|
||||
attempted: true,
|
||||
applied: false,
|
||||
traceId: normalized?.trace_id ?? null,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
effectiveMessage: userMessage,
|
||||
reason: "normalized_fragment_rejected_selected_object_context_loss",
|
||||
fallbackRuleHit: null,
|
||||
sanitizedUserMessage
|
||||
}, userMessage);
|
||||
}
|
||||
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
|
||||
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
|
||||
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
|
||||
|
||||
@@ -114,6 +114,42 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses analysis date hint for canonical item provenance wording without explicit date", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2019-02-12T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000003 от 12.02.2019 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 3724.17,
|
||||
SubcontoDt1: "Столешница 600*3050*26 дуб ниагара",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "Торговый дом \\Союз",
|
||||
SubcontoKt2: "Договор поставки № 12 от 01.02.2019",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("От какого поставщика куплен товар Столешница 600*3050*26 дуб ниагара", {
|
||||
analysisDateHint: "2019-03-31"
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2019-03-31");
|
||||
expect(result?.debug.reasons).toContain("as_of_date_from_analysis_context");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз");
|
||||
});
|
||||
|
||||
it("handles selected-object supplier slang 'кто это поставил нам' as provenance follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
@@ -160,6 +196,153 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(String(result?.reply_text ?? "")).toContain("Торговый дом \\Союз МСК\\");
|
||||
});
|
||||
|
||||
it("handles selected-object colloquial supplier wording 'у кого купили' as provenance follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2016-07-20T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000011 от 20.07.2016 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 695360,
|
||||
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "ООО \\Производство мебели\\",
|
||||
SubcontoKt2: "Договор поставки № 7 от 15.07.2016",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили',
|
||||
{
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2016-07-31",
|
||||
period_from: "2016-07-01",
|
||||
period_to: "2016-07-31",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-07-31");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
|
||||
});
|
||||
|
||||
it("handles selected-object wording 'где мы купили это' as provenance follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2016-05-20T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000009 от 20.05.2016 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 695360,
|
||||
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "ООО \\Производство мебели\\",
|
||||
SubcontoKt2: "Договор поставки № 5 от 16.05.2016",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||||
{
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2016-05-31",
|
||||
period_from: "2016-05-01",
|
||||
period_to: "2016-05-31",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
|
||||
});
|
||||
|
||||
it("handles selected-object wording 'где куплено!!' as provenance follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2016-05-20T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000009 от 20.05.2016 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 695360,
|
||||
SubcontoDt1: "Рабочая станция универсального специалиста (индивидуальное изготовление)",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "ООО \\Производство мебели\\",
|
||||
SubcontoKt2: "Договор поставки № 5 от 16.05.2016",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!',
|
||||
{
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2016-05-31",
|
||||
period_from: "2016-05-01",
|
||||
period_to: "2016-05-31",
|
||||
warehouse: "Основной склад",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Рабочая станция универсального специалиста (индивидуальное изготовление)");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2016-05-31");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Производство мебели\\");
|
||||
});
|
||||
|
||||
it("handles selected-object purchase-doc slang 'по каким документам это купили' as exact purchase-doc follow-up", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
|
||||
@@ -203,6 +203,39 @@ describe("address query shape classifier", () => {
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps selected-object colloquial supplier wording 'у кого купили' in inventory provenance intent", () => {
|
||||
const mode = detectAddressQuestionMode(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили'
|
||||
);
|
||||
const result = resolveAddressIntent(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили'
|
||||
);
|
||||
expect(mode.mode).toBe("address_query");
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps selected-object colloquial supplier wording 'где мы купили это' in inventory provenance intent", () => {
|
||||
const mode = detectAddressQuestionMode(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это'
|
||||
);
|
||||
const result = resolveAddressIntent(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это'
|
||||
);
|
||||
expect(mode.mode).toBe("address_query");
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps selected-object terse supplier wording 'где куплено!!' in inventory provenance intent", () => {
|
||||
const mode = detectAddressQuestionMode(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!'
|
||||
);
|
||||
const result = resolveAddressIntent(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!'
|
||||
);
|
||||
expect(mode.mode).toBe("address_query");
|
||||
expect(result.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
});
|
||||
|
||||
it("keeps selected-object purchase-doc slang with 'по каким документам это купили' in purchase-doc intent", () => {
|
||||
const mode = detectAddressQuestionMode(
|
||||
'По выбранному объекту "Столешница 600*3050*26 дуб ниагара": по каким документам это купили'
|
||||
@@ -2845,6 +2878,14 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(result.warnings).toContain("as_of_date_derived_from_period_to");
|
||||
});
|
||||
|
||||
it("derives month period for inventory snapshot from prepositional month wording 'в мае 2016'", () => {
|
||||
const result = extractAddressFilters("Что лежит на складе в мае 2016 года?", "inventory_on_hand_as_of_date");
|
||||
expect(result.extracted_filters.period_from).toBe("2016-05-01");
|
||||
expect(result.extracted_filters.period_to).toBe("2016-05-31");
|
||||
expect(result.extracted_filters.as_of_date).toBe("2016-05-31");
|
||||
expect(result.warnings).toContain("period_derived_from_month_phrase");
|
||||
});
|
||||
|
||||
it("extracts dotted account by heuristic for docs-forming phrasing without 'счет' keyword", () => {
|
||||
const result = extractAddressFilters(
|
||||
"раскрой остаток 60.01 по документам на конец июля 2020",
|
||||
@@ -3139,6 +3180,16 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
});
|
||||
|
||||
describe("address query limited taxonomy and stage diagnostics", { timeout: 15000 }, () => {
|
||||
it("does not default standalone item provenance questions to today without explicit temporal cue", () => {
|
||||
const result = extractAddressFilters(
|
||||
"От какого поставщика куплен товар Столешница 600*3050*26 дуб ниагара",
|
||||
"inventory_purchase_provenance_for_item"
|
||||
);
|
||||
expect(result.extracted_filters.item).toBe("Столешница 600*3050*26 дуб ниагара");
|
||||
expect(result.extracted_filters.as_of_date).toBeUndefined();
|
||||
expect(result.warnings).not.toContain("as_of_date_defaulted_today");
|
||||
});
|
||||
|
||||
it("injects as_of_date from analysis context when user message has no explicit period", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("Покажи контрагентов с незакрытыми хвостами", {
|
||||
@@ -3924,6 +3975,78 @@ describe("address decompose stage follow-up carryover", () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2019-03-31");
|
||||
expect(
|
||||
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
|
||||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes selected-object wording 'у кого купили' into inventory provenance with inherited date context", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": у кого купили',
|
||||
{
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2016-07-31",
|
||||
period_from: "2016-07-01",
|
||||
period_to: "2016-07-31",
|
||||
warehouse: "Основной склад"
|
||||
},
|
||||
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.as_of_date).toBe("2016-07-31");
|
||||
expect(
|
||||
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
|
||||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes selected-object wording 'где мы купили это' into inventory provenance with inherited date context", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||||
{
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2016-05-31",
|
||||
period_from: "2016-05-01",
|
||||
period_to: "2016-05-31",
|
||||
warehouse: "Основной склад"
|
||||
},
|
||||
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.as_of_date).toBe("2016-05-31");
|
||||
expect(
|
||||
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
|
||||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes selected-object wording 'где куплено!!' into inventory provenance with inherited date context", () => {
|
||||
const result = runAddressDecomposeStage(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где куплено!!',
|
||||
{
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2016-05-31",
|
||||
period_from: "2016-05-01",
|
||||
period_to: "2016-05-31",
|
||||
warehouse: "Основной склад"
|
||||
},
|
||||
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.as_of_date).toBe("2016-05-31");
|
||||
expect(
|
||||
result?.baseReasons?.includes("intent_adjusted_to_inventory_followup_context") ||
|
||||
result?.intent.reasons.includes("inventory_selected_object_provenance_signal_detected")
|
||||
|
||||
@@ -350,6 +350,105 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
expect(response.debug?.llm_decomposition_reason).not.toBe("normalized_fragment_applied");
|
||||
});
|
||||
|
||||
it("rejects selected-object rewrite that drops object context and injects generic documents intent", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
trace_id: "norm-predecompose-selected-object-context-loss",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||||
normalized_fragment_text: "Покупка рабочей станции универсального специалиста (индивидуальное изготовление). Кто поставщик и какие документы подтверждают?",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: [],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: true,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "medium",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: `asst-predecompose-selected-object-${Date.now()}`,
|
||||
user_message: 'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это',
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].message).toBe(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": где мы купили это'
|
||||
);
|
||||
expect([
|
||||
"normalized_fragment_rejected_intent_conflict",
|
||||
"normalized_fragment_rejected_selected_object_context_loss"
|
||||
]).toContain(response.debug?.llm_decomposition_reason);
|
||||
});
|
||||
|
||||
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
|
||||
Reference in New Issue
Block a user