АДРЕСНЫЙ РЕЖИМ - ADDRESS: стабилизация интерпретации сленга и mixed RU/EN, расширение intent/mode-роутинга, регресс-тесты и live stress 102/102
This commit is contained in:
@@ -721,19 +721,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
|
||||
}
|
||||
}
|
||||
|
||||
// For document/bank lists we default to a short recent window if no explicit period was provided.
|
||||
if (
|
||||
(intent === "list_documents_by_counterparty" ||
|
||||
intent === "bank_operations_by_counterparty") &&
|
||||
!filters.period_from &&
|
||||
!filters.period_to &&
|
||||
!hasAllTimeHint(text)
|
||||
) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
filters.period_to = today;
|
||||
filters.period_from = shiftDaysIso(today, -90);
|
||||
warnings.push("period_defaulted_last_90_days");
|
||||
}
|
||||
// For counterparty document/bank lists we keep period open by default (all-time over available data)
|
||||
// and rely on runtime limits/recovery instead of forcing a recent window.
|
||||
|
||||
// For balance-style intents we force as_of_date deterministically:
|
||||
// - explicit as_of has priority;
|
||||
|
||||
@@ -28,14 +28,20 @@ const ACCOUNT_BALANCE_HINTS = [
|
||||
"account balance",
|
||||
"balance by account",
|
||||
"saldo",
|
||||
"баланс",
|
||||
"остаток по счет",
|
||||
"сальдо по счет",
|
||||
"по счету"
|
||||
"по счету",
|
||||
"что на счете",
|
||||
"что на счёте",
|
||||
"на конец"
|
||||
];
|
||||
|
||||
const DOCUMENTS_FORMING_BALANCE_HINTS = [
|
||||
"documents forming balance",
|
||||
"docs forming balance",
|
||||
"documents form balance",
|
||||
"docs form balance",
|
||||
"balance documents",
|
||||
"documents for balance",
|
||||
"which documents form balance",
|
||||
@@ -61,6 +67,8 @@ const OPEN_ITEMS_HINTS = [
|
||||
"висят",
|
||||
"незакрыт",
|
||||
"открыт",
|
||||
"долг",
|
||||
"задолж",
|
||||
"позици"
|
||||
];
|
||||
|
||||
@@ -93,14 +101,21 @@ const BANK_OPERATIONS_BY_COUNTERPARTY_HINTS = [
|
||||
"bank operations by customer",
|
||||
"show bank operations by counterparty",
|
||||
"bank ops",
|
||||
"bank oper",
|
||||
"transactions by counterparty",
|
||||
"транзак",
|
||||
"банк",
|
||||
"банков",
|
||||
"по банку",
|
||||
"опер",
|
||||
"выписк",
|
||||
"платеж",
|
||||
"платёж",
|
||||
"оплат",
|
||||
"списан",
|
||||
"списани",
|
||||
"поступлен",
|
||||
"поступлени",
|
||||
"движени"
|
||||
];
|
||||
const DOCUMENTS_BY_CONTRACT_HINTS = [
|
||||
@@ -126,7 +141,10 @@ const BANK_OPERATIONS_BY_CONTRACT_HINTS = [
|
||||
];
|
||||
|
||||
const BANK_OPERATION_CORE_HINTS = [
|
||||
"банк",
|
||||
"банков",
|
||||
"операц",
|
||||
"опер",
|
||||
"выписк",
|
||||
"платеж",
|
||||
"платёж",
|
||||
@@ -148,18 +166,62 @@ function hasAny(text: string, patterns: string[]): boolean {
|
||||
return patterns.some((item) => text.includes(item));
|
||||
}
|
||||
|
||||
function hasCompactAccountCodeToken(text: string): boolean {
|
||||
// Match compact account tokens like 60.01 / 62, while avoiding date fragments.
|
||||
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})(?![\d-])/u.test(text);
|
||||
}
|
||||
|
||||
function hasDocumentsFormingBalanceSignal(text: string): boolean {
|
||||
if (hasAny(text, DOCUMENTS_FORMING_BALANCE_HINTS)) {
|
||||
return true;
|
||||
}
|
||||
const hasDocLexeme = text.includes("документ") || text.includes("доки");
|
||||
const hasLooseAccountCodeToken = hasCompactAccountCodeToken(text);
|
||||
const hasDocLexeme = /(?:документ|док(?:и|ам|ах|ов|а)?)/u.test(text);
|
||||
const hasFormingLexeme = text.includes("формир");
|
||||
const hasBalanceLexeme = text.includes("остат");
|
||||
const hasAccountLexeme = text.includes("счет") || text.includes("счёт") || hasAccountNumberAnchor(text);
|
||||
const hasAccountLexeme =
|
||||
text.includes("счет") || text.includes("счёт") || hasAccountNumberAnchor(text) || hasLooseAccountCodeToken;
|
||||
if (hasDocLexeme && hasFormingLexeme && hasBalanceLexeme && hasAccountLexeme) {
|
||||
return true;
|
||||
}
|
||||
return hasBalanceLexeme && hasAccountLexeme && text.includes("из чего состоит");
|
||||
if (
|
||||
hasDocLexeme &&
|
||||
hasBalanceLexeme &&
|
||||
hasAccountLexeme &&
|
||||
(text.includes("раскрой") || text.includes("раскид") || text.includes("под остатк"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (hasBalanceLexeme && hasAccountLexeme && text.includes("из чего состоит")) {
|
||||
return true;
|
||||
}
|
||||
return hasBalanceLexeme && hasAccountLexeme && /из\s+чего\s+остат/u.test(text);
|
||||
}
|
||||
|
||||
function hasDocumentsFormingBalanceAccountAnchor(text: string): boolean {
|
||||
if (hasAccountNumberAnchor(text) || text.includes("счет") || text.includes("счёт")) {
|
||||
return true;
|
||||
}
|
||||
// Allow compact account mentions like "60.01" in slang prompts without explicit "счет".
|
||||
return hasCompactAccountCodeToken(text);
|
||||
}
|
||||
|
||||
function hasAccountBalanceSignal(text: string): boolean {
|
||||
if (hasAny(text, ACCOUNT_BALANCE_HINTS)) {
|
||||
return true;
|
||||
}
|
||||
const hasAccountLexeme =
|
||||
hasAccountNumberAnchor(text) || hasCompactAccountCodeToken(text) || /(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/u.test(text);
|
||||
const hasBalanceLexeme =
|
||||
text.includes("баланс") ||
|
||||
text.includes("остат") ||
|
||||
text.includes("сальд") ||
|
||||
text.includes("saldo") ||
|
||||
text.includes("balance") ||
|
||||
text.includes("скока") ||
|
||||
text.includes("сколько") ||
|
||||
/на\s+конец/u.test(text);
|
||||
return hasAccountLexeme && hasBalanceLexeme;
|
||||
}
|
||||
|
||||
function isLikelyCounterpartyToken(rawToken: string): boolean {
|
||||
@@ -241,6 +303,8 @@ function hasPartyAnchorMention(text: string): boolean {
|
||||
function hasContractAnchorMention(text: string): boolean {
|
||||
return (
|
||||
text.includes("договор") ||
|
||||
text.includes("контракт") ||
|
||||
/\bдог\.?\b/iu.test(text) ||
|
||||
text.includes("дог.") ||
|
||||
text.includes("contract") ||
|
||||
text.includes("dogovor")
|
||||
@@ -265,6 +329,10 @@ function hasContractNumberLikeToken(text: string): boolean {
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (/^\d{1,2}\.\d{1,2}$/u.test(token)) {
|
||||
// Likely an account code like 60.01/51.00, not a contract number.
|
||||
continue;
|
||||
}
|
||||
const parts = token.split(/[./_-]+/u).map((part) => Number(part));
|
||||
if (!parts.every((part) => Number.isFinite(part))) {
|
||||
return true;
|
||||
@@ -381,7 +449,7 @@ function hasDocumentSignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasHeuristicCounterpartyAnchor(text: string): boolean {
|
||||
if (!hasDocsOrBankSignal(text)) {
|
||||
if (!hasDocsOrBankSignal(text) && !hasBankOperationSignal(text)) {
|
||||
return false;
|
||||
}
|
||||
const tokens = String(text ?? "")
|
||||
@@ -441,7 +509,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (hasDocumentsFormingBalanceSignal(text) && (hasAccountNumberAnchor(text) || text.includes("счет"))) {
|
||||
if (hasDocumentsFormingBalanceSignal(text) && hasDocumentsFormingBalanceAccountAnchor(text)) {
|
||||
return {
|
||||
intent: "documents_forming_balance",
|
||||
confidence: "high",
|
||||
@@ -449,6 +517,17 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
hasAny(text, OPEN_ITEMS_HINTS) &&
|
||||
(text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))
|
||||
) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "medium",
|
||||
reasons: ["open_items_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
hasContractAnchorSignal(text) &&
|
||||
hasBankOperationSignal(text)
|
||||
@@ -496,7 +575,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAny(text, ACCOUNT_BALANCE_HINTS) || hasAccountNumberAnchor(text)) {
|
||||
if (hasAccountBalanceSignal(text)) {
|
||||
return {
|
||||
intent: "account_balance_snapshot",
|
||||
confidence: "high",
|
||||
@@ -512,14 +591,6 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAny(text, OPEN_ITEMS_HINTS) && (text.includes("контраг") || text.includes("договор") || text.includes("counterparty") || text.includes("contract"))) {
|
||||
return {
|
||||
intent: "open_items_by_counterparty_or_contract",
|
||||
confidence: "medium",
|
||||
reasons: ["open_items_signal_detected"]
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAny(text, OPEN_CONTRACTS_HINTS) && (text.includes("договор") || text.includes("contract"))) {
|
||||
return {
|
||||
intent: "list_open_contracts",
|
||||
|
||||
@@ -22,7 +22,11 @@ const ADDRESS_ACTION_TOKENS = [
|
||||
"кому",
|
||||
"какие",
|
||||
"что по",
|
||||
"че по",
|
||||
"чё по",
|
||||
"остаток",
|
||||
"скока",
|
||||
"сколько",
|
||||
"долг",
|
||||
"задолж",
|
||||
"хвост",
|
||||
@@ -64,6 +68,7 @@ const ADDRESS_ENTITY_TOKENS = [
|
||||
"банк",
|
||||
"выписк",
|
||||
"операц",
|
||||
"транзак",
|
||||
"договор",
|
||||
"счет",
|
||||
"счёт",
|
||||
@@ -152,11 +157,15 @@ function hasAddressFollowupSignal(text: string): boolean {
|
||||
}
|
||||
|
||||
function hasDocsOrBankSignal(text: string): boolean {
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||
return /(?:док(?:и|умент|ументы|ументов)|docs?|documents?|банк|выписк|платеж|платёж|оплат|поступлен|списан|транзак|transactions?|bank\s+ops|bank\s+operations?)/iu.test(
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
function hasAccountCodeAnchor(text: string): boolean {
|
||||
return /(?<![\d-])\d{2}(?:[.,]\d{1,2})(?![\d-])/u.test(text);
|
||||
}
|
||||
|
||||
function hasLikelyCounterpartyToken(text: string): boolean {
|
||||
const stopWords = new Set([
|
||||
"за",
|
||||
@@ -247,8 +256,9 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
const hasDeepReasoning = hasAnyToken(text, DEEP_REASONING_TOKENS);
|
||||
const hasLooseByAnchor = hasLooseByAnchorMention(text);
|
||||
const hasFollowupSignal = hasAddressFollowupSignal(text);
|
||||
const hasAccountCode = hasAccountCodeAnchor(text);
|
||||
|
||||
if (hasAddressAction && hasAddressEntity && !hasDeepReasoning) {
|
||||
if (hasAddressAction && (hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
confidence: "high",
|
||||
@@ -256,7 +266,7 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
};
|
||||
}
|
||||
|
||||
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal) && !hasDeepReasoning) {
|
||||
if (hasLooseByAnchor && (hasAddressAction || hasAddressEntity || hasFollowupSignal || hasAccountCode) && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
confidence: "medium",
|
||||
@@ -264,7 +274,7 @@ export function detectAddressQuestionMode(userMessage: string): AddressModeDetec
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAddressEntity && !hasDeepReasoning) {
|
||||
if ((hasAddressEntity || hasAccountCode) && !hasDeepReasoning) {
|
||||
return {
|
||||
mode: "address_query",
|
||||
confidence: "medium",
|
||||
|
||||
@@ -996,6 +996,68 @@ export class AddressQueryService {
|
||||
? "rows_filtered_out_by_intent_recipe_after_anchor_match"
|
||||
: null;
|
||||
|
||||
if (filteredRows.length === 0 && intent.intent === "list_documents_by_contract" && filterByAnchors.length > 0) {
|
||||
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
|
||||
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
|
||||
if (recoveredRows.length > 0) {
|
||||
const factual = composeFactualReply(intent.intent, recoveredRows);
|
||||
const recoveryReason =
|
||||
recoveredBankRows.length > 0
|
||||
? "contract_docs_recovered_via_bank_fallback"
|
||||
: "contract_docs_recovered_via_anchor_rows";
|
||||
const replyPrefix =
|
||||
recoveredBankRows.length > 0
|
||||
? "Документный фильтр в live дал пустой набор; показываю связанные банковские операции по договору."
|
||||
: "Документный фильтр в live дал пустой набор; показываю найденные строки по договорному якорю.";
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: `${replyPrefix}\n${factual.text}`,
|
||||
reply_type: inferReplyType(factual.responseType),
|
||||
response_type: factual.responseType,
|
||||
debug: {
|
||||
detected_mode: mode.mode,
|
||||
detected_mode_confidence: mode.confidence,
|
||||
query_shape: shape.shape,
|
||||
query_shape_confidence: shape.confidence,
|
||||
detected_intent: intent.intent,
|
||||
detected_intent_confidence: intent.confidence,
|
||||
extracted_filters: filters.extracted_filters,
|
||||
missing_required_filters: [],
|
||||
selected_recipe: recipeSelection.selected_recipe.recipe_id,
|
||||
mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"),
|
||||
account_scope_mode: plan.account_scope_mode,
|
||||
account_scope_fallback_applied: accountScopeFallbackApplied,
|
||||
anchor_type: anchor.anchor_type,
|
||||
anchor_value_raw: anchor.anchor_value_raw,
|
||||
anchor_value_resolved: anchor.anchor_value_resolved,
|
||||
resolver_confidence: anchor.resolver_confidence,
|
||||
ambiguity_count: anchor.ambiguity_count,
|
||||
match_failure_stage: "none",
|
||||
match_failure_reason: null,
|
||||
mcp_call_status: "matched_non_empty",
|
||||
rows_fetched: mcp.fetched_rows,
|
||||
raw_rows_received: mcp.raw_rows.length,
|
||||
rows_after_account_scope: normalizedRows.length,
|
||||
rows_after_recipe_filter: filterByAnchors.length,
|
||||
rows_materialized: normalizedRows.length,
|
||||
rows_matched: recoveredRows.length,
|
||||
raw_row_keys_sample: rowDiagnostics.rawRowKeysSample,
|
||||
materialization_drop_reason: rowDiagnostics.materializationDropReason,
|
||||
account_token_raw: accountScopeAudit.accountTokenRaw,
|
||||
account_token_normalized: accountScopeAudit.accountTokenNormalized,
|
||||
account_scope_fields_checked: accountScopeAudit.accountScopeFieldsChecked,
|
||||
account_scope_match_strategy: accountScopeAudit.accountScopeMatchStrategy,
|
||||
account_scope_drop_reason: accountScopeAudit.accountScopeDropReason,
|
||||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
limited_reason_category: null,
|
||||
response_type: factual.responseType,
|
||||
limitations: [...filters.warnings, recoveryReason],
|
||||
reasons: [...baseReasons, recoveryReason]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent.intent === "list_open_contracts" && filteredRows.length > 0 && contractCandidatesFromRows(filteredRows).length === 0) {
|
||||
return buildLimitedExecutionResult({
|
||||
mode,
|
||||
|
||||
@@ -13,6 +13,8 @@ import * as companyAnchorResolver_1 from "./companyAnchorResolver";
|
||||
import * as assistantRuntimeGuards_1 from "./assistantRuntimeGuards";
|
||||
import * as assistantClaimBoundEvidence_1 from "./assistantClaimBoundEvidence";
|
||||
import * as addressQueryService_1 from "./addressQueryService";
|
||||
import * as addressQueryClassifier_1 from "./addressQueryClassifier";
|
||||
import * as addressIntentResolver_1 from "./addressIntentResolver";
|
||||
import iconv from "iconv-lite";
|
||||
function retrievalSummaryForRoute(route) {
|
||||
if (route === "store_canonical")
|
||||
@@ -1924,9 +1926,9 @@ const ADDRESS_MONTH_ALIAS_MAP = {
|
||||
december: "12",
|
||||
dec: "12"
|
||||
};
|
||||
const ADDRESS_DOCS_SIGNAL_PATTERN = /(?:док|доки|документ|документы|документов|docs?|documents?|bank|выписк|плат[её]ж|оплат|поступлен|списан|операц)/i;
|
||||
const ADDRESS_BANK_SIGNAL_PATTERN = /(?:bank|банк|банков|выписк|плат[её]ж|оплат|поступлен|списан|операц|расчетн)/i;
|
||||
const ADDRESS_CONTRACT_SIGNAL_PATTERN = /(?:договор(?:а|у|ом|е)?|\bcontract\b)/iu;
|
||||
const ADDRESS_DOCS_SIGNAL_PATTERN = /(?:док|доки|документ|документы|документов|docs?|documents?|bank|выписк|плат[её]ж|оплат|поступлен|списан|операц|опер|transaction)/i;
|
||||
const ADDRESS_BANK_SIGNAL_PATTERN = /(?:bank|банк|банков|выписк|плат[её]ж|оплат|поступлен|списан|операц|опер|расчетн|транзак)/i;
|
||||
const ADDRESS_CONTRACT_SIGNAL_PATTERN = /(?:договор(?:а|у|ом|е)?|(?:^|[^\p{L}\p{N}_])(?:дог\.?|[dд][oо][gг]\.?|dog\.?)(?=$|[^\p{L}\p{N}_])|contract|dogovor)/iu;
|
||||
const ADDRESS_BALANCE_SIGNAL_PATTERN = /(?:остат|сальдо|баланс|взаиморасч|долг|saldo|balance)/i;
|
||||
const ADDRESS_ALL_TIME_PATTERN = /(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|for\s+all\s+time|all\s+time|entire\s+period|full\s+history)/iu;
|
||||
function normalizeAddressMonthAliasToken(token) {
|
||||
@@ -1964,6 +1966,10 @@ function sanitizeAddressMessageForFallback(userMessage) {
|
||||
.replace(/\bpokezh\b/giu, "покажи")
|
||||
.replace(/\bpokazh(?:i)?\b/giu, "покажи")
|
||||
.replace(/\bpokaji\b/giu, "покажи")
|
||||
.replace(/\bop(?:er|ers?)\b/giu, "операции")
|
||||
.replace(/(^|[^\p{L}\p{N}_])опер(?:аци[яиюе]|ы|)?(?=$|[^\p{L}\p{N}_])/giu, "$1операции")
|
||||
.replace(/(^|[^\p{L}\p{N}_])дог\.?(?=$|[^\p{L}\p{N}_])/giu, "$1договор")
|
||||
.replace(/(^|[^\p{L}\p{N}_])dog\.?(?=$|[^\p{L}\p{N}_])/giu, "$1contract")
|
||||
.replace(/\bdok(?:i|y)?\b/giu, "доки")
|
||||
.replace(/\bdocuments?\b/giu, "документы")
|
||||
.replace(/\bdocs?\b/giu, "документы")
|
||||
@@ -2070,7 +2076,7 @@ function pickAddressFallbackCounterpartyToken(text) {
|
||||
!/^\d{2}(?:\.\d{1,2})?$/.test(normalizedByToken) &&
|
||||
!/^(?:19|20)\d{2}$/.test(normalizedByToken) &&
|
||||
!/^(?:янв|фев|мар|апр|май|июн|июл|авг|сен|сент|окт|ноя|дек|january|february|march|april|may|june|july|august|september|october|november|december)/i.test(normalizedByToken) &&
|
||||
!/^(?:договор|договора|договору|договором|договоре|contract)$/.test(normalizedByToken)) {
|
||||
!/^(?:договор|договора|договору|договором|договоре|contract|dogovor|dog|дог|d[oо]g|д[oо]г)$/.test(normalizedByToken)) {
|
||||
return byToken;
|
||||
}
|
||||
}
|
||||
@@ -2086,7 +2092,7 @@ function pickAddressFallbackCounterpartyToken(text) {
|
||||
if (/^(?:янв|фев|мар|апр|май|июн|июл|авг|сен|сент|окт|ноя|дек|january|february|march|april|may|june|july|august|september|october|november|december)/i.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
if (/^(?:договор|договора|договору|договором|договоре|contract)$/.test(normalized)) {
|
||||
if (/^(?:договор|договора|договору|договором|договоре|contract|dogovor|dog|дог|d[oо]g|д[oо]г)$/.test(normalized)) {
|
||||
continue;
|
||||
}
|
||||
return token;
|
||||
@@ -2096,8 +2102,8 @@ function pickAddressFallbackCounterpartyToken(text) {
|
||||
function extractAddressFallbackContractToken(text) {
|
||||
const source = String(text ?? "");
|
||||
const patterns = [
|
||||
/(?:договор(?:а|у|ом|е)?|\bcontract\b)\s*(?:№|#|n|no\.?)?\s*([a-zа-я0-9][a-zа-я0-9/_-]{1,})/iu,
|
||||
/(?:№|#|n|no\.?)\s*([a-zа-я0-9][a-zа-я0-9/_-]{1,})\s*(?:договор(?:а|у|ом|е)?|\bcontract\b)/iu
|
||||
/(?:договор(?:а|у|ом|е)?|дог\.?|[dд][oо][gг]\.?|contract|dogovor|dog\.?)\s*(?:№|#|n|no\.?)?\s*([a-zа-я0-9][a-zа-я0-9/_-]{1,})/iu,
|
||||
/(?:№|#|n|no\.?)\s*([a-zа-я0-9][a-zа-я0-9/_-]{1,})\s*(?:договор(?:а|у|ом|е)?|дог\.?|[dд][oо][gг]\.?|contract|dogovor|dog\.?)/iu
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(source);
|
||||
@@ -2116,7 +2122,7 @@ function extractAddressFallbackContractToken(text) {
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
if (ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source)) {
|
||||
if (ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source) || /(?:^|[^\p{L}\p{N}_])(?:[dд][oо][gг]|dogovor)(?=$|[^\p{L}\p{N}_])/iu.test(source)) {
|
||||
const generic = source.match(/\b([a-zа-я0-9]{1,10}[/-][a-zа-я0-9]{1,10}(?:[/-][a-zа-я0-9]{1,10})?)\b/iu);
|
||||
if (generic && generic[1]) {
|
||||
return generic[1];
|
||||
@@ -2157,9 +2163,36 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!docsSignal && !contractSignal && !balanceSignal) {
|
||||
const counterparty = pickAddressFallbackCounterpartyToken(source);
|
||||
const genericLookupSignal = /(?:\bесть\b|\bпокажи\b|\bвыведи\b|\bч[её]\b|\bчто\b)/iu.test(source);
|
||||
if (counterparty && (allTime || monthYear || year) && genericLookupSignal) {
|
||||
let periodClause = "";
|
||||
let rule = "documents_counterparty_rewrite_from_generic_lookup";
|
||||
if (allTime) {
|
||||
periodClause = " за все время";
|
||||
rule = "documents_counterparty_all_time_rewrite_from_generic_lookup";
|
||||
}
|
||||
else if (monthYear) {
|
||||
periodClause = ` за ${monthYear}`;
|
||||
rule = "documents_counterparty_month_rewrite_from_generic_lookup";
|
||||
}
|
||||
else if (year) {
|
||||
periodClause = ` за ${year} год`;
|
||||
rule = "documents_counterparty_year_rewrite_from_generic_lookup";
|
||||
}
|
||||
const candidate = compactWhitespace(`документы по контрагенту ${counterparty}${periodClause}`);
|
||||
if (candidate && candidate !== sourceRaw.toLowerCase()) {
|
||||
return {
|
||||
candidate,
|
||||
rule
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (docsSignal) {
|
||||
if (contractSignal) {
|
||||
const contract = extractAddressFallbackContractToken(sourceRaw || source);
|
||||
const contract = extractAddressFallbackContractToken(sourceRaw || source);
|
||||
if (contractSignal || contract) {
|
||||
if (contract) {
|
||||
let periodClause = "";
|
||||
let rule = bankSignal ? "bank_operations_contract_rewrite" : "documents_contract_rewrite";
|
||||
@@ -2642,6 +2675,32 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
|
||||
reason: normalized?.ok ? "no_usable_fragment" : "normalize_failed"
|
||||
};
|
||||
}
|
||||
const repairedSourceMessage = repairAddressMojibake(userMessage);
|
||||
const sourceIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(repairedSourceMessage || userMessage);
|
||||
const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate);
|
||||
const sourceIntentKnown = sourceIntentResolution.intent !== "unknown";
|
||||
const candidateIntentKnown = candidateIntentResolution.intent !== "unknown";
|
||||
const intentConflict = sourceIntentKnown &&
|
||||
candidateIntentKnown &&
|
||||
sourceIntentResolution.intent !== candidateIntentResolution.intent;
|
||||
const intentDroppedByCandidate = sourceIntentKnown && !candidateIntentKnown;
|
||||
const rejectCandidateForIntentSafety = intentDroppedByCandidate ||
|
||||
(intentConflict &&
|
||||
(sourceIntentResolution.confidence === "high" || candidateIntentResolution.confidence !== "high"));
|
||||
if (rejectCandidateForIntentSafety) {
|
||||
return {
|
||||
...baseMeta,
|
||||
attempted: true,
|
||||
applied: false,
|
||||
traceId: normalized?.trace_id ?? null,
|
||||
effectiveMessage: userMessage,
|
||||
reason: intentDroppedByCandidate
|
||||
? "normalized_fragment_rejected_intent_drop"
|
||||
: "normalized_fragment_rejected_intent_conflict",
|
||||
fallbackRuleHit: null,
|
||||
sanitizedUserMessage
|
||||
};
|
||||
}
|
||||
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
|
||||
const candidateCompact = compactWhitespace(candidate.toLowerCase());
|
||||
const applied = sourceCompact !== candidateCompact;
|
||||
@@ -2692,12 +2751,19 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
|
||||
}
|
||||
}
|
||||
function resolveAddressToolGateDecision(addressInputMessage, followupContext) {
|
||||
const hasMessageSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) || hasAccountingSignal(addressInputMessage);
|
||||
const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? ""));
|
||||
const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage);
|
||||
const hasClassifierSignal = modeDetection.mode === "address_query";
|
||||
const hasMessageSignal = hasClassifierSignal ||
|
||||
isAddressLlmPreDecomposeCandidate(addressInputMessage) ||
|
||||
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
|
||||
hasAccountingSignal(addressInputMessage) ||
|
||||
hasAccountingSignal(repairedInputMessage);
|
||||
if (hasMessageSignal) {
|
||||
return {
|
||||
runAddressLane: true,
|
||||
decision: "run_address_lane",
|
||||
reason: "address_signal_detected"
|
||||
reason: hasClassifierSignal ? "address_mode_classifier_detected" : "address_signal_detected"
|
||||
};
|
||||
}
|
||||
if (followupContext) {
|
||||
|
||||
@@ -132,6 +132,9 @@ function shouldFallbackToChatCompletions(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return false;
|
||||
}
|
||||
if (error.code === "OPENAI_OUTPUT_PARSE_FAILED" || error.code === "OPENAI_NON_JSON_RESPONSE") {
|
||||
return true;
|
||||
}
|
||||
if (error.code !== "OPENAI_REQUEST_FAILED") {
|
||||
return false;
|
||||
}
|
||||
@@ -144,6 +147,35 @@ function shouldFallbackToChatCompletions(error: unknown): boolean {
|
||||
return message.includes("/responses") || message.includes("responses");
|
||||
}
|
||||
|
||||
function extractModelErrorMessage(data: Record<string, unknown>): string | null {
|
||||
const rawError = data.error;
|
||||
if (typeof rawError === "string" && rawError.trim().length > 0) {
|
||||
return rawError.trim();
|
||||
}
|
||||
if (rawError && typeof rawError === "object") {
|
||||
const errorObj = rawError as Record<string, unknown>;
|
||||
const message = errorObj.message;
|
||||
if (typeof message === "string" && message.trim().length > 0) {
|
||||
return message.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRouteMismatchErrorMessage(message: string): boolean {
|
||||
const source = String(message ?? "").toLowerCase();
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
/unexpected endpoint|unexpected route|unknown endpoint|unknown route|unsupported endpoint|unsupported route/.test(source) ||
|
||||
(/endpoint/.test(source) && /method/.test(source)) ||
|
||||
(/endpoint/.test(source) && /not found/.test(source)) ||
|
||||
(/route/.test(source) && /not found/.test(source)) ||
|
||||
(/path/.test(source) && /not found/.test(source))
|
||||
);
|
||||
}
|
||||
|
||||
function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
|
||||
const schemaFile =
|
||||
schemaVersion === "v1"
|
||||
@@ -398,11 +430,16 @@ export class OpenAIResponsesClient {
|
||||
}
|
||||
}
|
||||
|
||||
const modelErrorMessage = extractModelErrorMessage(data);
|
||||
if (modelErrorMessage && canFallbackToAlternativeBase && !isLastCandidate && isRouteMismatchErrorMessage(modelErrorMessage)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorObj = (data.error ?? {}) as Record<string, unknown>;
|
||||
throw new ApiError(
|
||||
"OPENAI_REQUEST_FAILED",
|
||||
String(errorObj.message ?? `Model endpoint failed: ${response.status}`),
|
||||
modelErrorMessage ?? String(errorObj.message ?? `Model endpoint failed: ${response.status}`),
|
||||
response.status,
|
||||
{
|
||||
route: routePath,
|
||||
@@ -414,6 +451,14 @@ export class OpenAIResponsesClient {
|
||||
);
|
||||
}
|
||||
|
||||
if (modelErrorMessage) {
|
||||
throw new ApiError("OPENAI_REQUEST_FAILED", modelErrorMessage, 502, {
|
||||
route: routePath,
|
||||
url,
|
||||
status: response.status
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user