АДРЕСНЫЙ РЕЖИМ - ADDRESS: стабилизация интерпретации сленга и mixed RU/EN, расширение intent/mode-роутинга, регресс-тесты и live stress 102/102

This commit is contained in:
2026-04-02 11:01:28 +03:00
parent 7dd6607ded
commit da8a4eb872
68 changed files with 29777 additions and 3220 deletions
@@ -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;
}