ОРРКЕСТРАЦИЯ - Оркестрация домена: ввести step-validator и починить исторический as_of_date в item-trace

This commit is contained in:
2026-04-14 23:01:23 +03:00
parent c020ef08e1
commit 82a020e302
16 changed files with 1414 additions and 75 deletions
@@ -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;