АДРЕСНЫЙ РЕЖИМ - 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;
}
@@ -34,6 +34,17 @@ describe("address query shape classifier", () => {
const result = detectAddressQuestionMode("за любой период есть что-то по свк?");
expect(result.mode).toBe("address_query");
});
it("keeps slang transaction phrasing in address lane", () => {
const result = detectAddressQuestionMode("транзакции по свк за 2020");
expect(result.mode).toBe("address_query");
});
it("keeps short balance slang with compact account token in address lane", () => {
const result = detectAddressQuestionMode("скока по 60.02 на конец 2020-12");
expect(result.mode).toBe("address_query");
});
});
describe("address compose stage utf8 headers", () => {
@@ -87,6 +98,16 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents forming balance for slang phrase with compact account token", () => {
const result = resolveAddressIntent("раскрой остаток 60.01 по документам на конец июля 2020");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents forming balance for 'доки под остатком' slang phrase", () => {
const result = resolveAddressIntent("доки под остатком 60.01 на 2020-07-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves documents by company phrase as counterparty intent", () => {
const result = resolveAddressIntent("Какие документы доступны по компании СВК за 2021 год?");
expect(result.intent).toBe("list_documents_by_counterparty");
@@ -107,6 +128,16 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves shorthand bank-by-contract slang intent", () => {
const result = resolveAddressIntent("покажи банк опер по дог 15/24 пж");
expect(result.intent).toBe("bank_operations_by_contract");
});
it("resolves debt-by-contract query to open items intent", () => {
const result = resolveAddressIntent("Есть ли долг по договору 15/24 на 2020-07-31");
expect(result.intent).toBe("open_items_by_counterparty_or_contract");
});
it("resolves bank operations by contract for normalized phrase with linked contract wording", () => {
const result = resolveAddressIntent(
"Показать банковские операции (счета 51, 60, 62) связанные с договором 15/24."
@@ -139,6 +170,36 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("list_documents_by_counterparty");
});
it("resolves slang transactions phrase by counterparty", () => {
const result = resolveAddressIntent("транзакции по свк за 2020");
expect(result.intent).toBe("bank_operations_by_counterparty");
});
it("resolves short balance slang with compact account token", () => {
const result = resolveAddressIntent("скока по 60.02 на конец 2020-12");
expect(result.intent).toBe("account_balance_snapshot");
});
it("resolves colloquial 'что на счете' phrasing as account balance snapshot", () => {
const result = resolveAddressIntent("что на счете 60 на 2020.05");
expect(result.intent).toBe("account_balance_snapshot");
});
it("resolves mixed ru/en balance phrasing with account token", () => {
const result = resolveAddressIntent("баланс account 60.01 as of 2020-07-31");
expect(result.intent).toBe("account_balance_snapshot");
});
it("resolves 'по докам' slang as documents forming balance", () => {
const result = resolveAddressIntent("раскидай остаток 62.01 по докам на 2020-12-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves english compact docs-forming phrasing", () => {
const result = resolveAddressIntent("docs forming balance 60.01 as of 2020-07-31");
expect(result.intent).toBe("documents_forming_balance");
});
it("resolves loose by-anchor follow-up as documents by counterparty fallback", () => {
const result = resolveAddressIntent("за любой период есть что-то по свк?");
expect(result.intent).toBe("list_documents_by_counterparty");
@@ -174,6 +235,17 @@ describe("address filter extraction for balance drilldown", () => {
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("keeps all-time period by default for counterparty docs query without explicit window", () => {
const result = extractAddressFilters(
"Покажи документы по контрагенту тестовый",
"list_documents_by_counterparty"
);
expect(result.extracted_filters.counterparty).toBe("тестовый");
expect(result.extracted_filters.period_from).toBeUndefined();
expect(result.extracted_filters.period_to).toBeUndefined();
expect(result.warnings).not.toContain("period_defaulted_last_90_days");
});
it("extracts counterparty from company phrase and derives year period", () => {
const result = extractAddressFilters(
"Какие документы доступны по компании СВК за 2021 год?",
@@ -62,9 +62,6 @@ describe("assistant address follow-up carryover", () => {
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === "какие есть доки по свк с 2020 по 2025 год") {
return buildAddressLaneResult();
}
if (message === "а за все время?" && !options?.followupContext) {
return null;
}
@@ -77,7 +74,7 @@ describe("assistant address follow-up carryover", () => {
}
});
}
return null;
return buildAddressLaneResult();
})
} as any;
@@ -101,7 +98,7 @@ describe("assistant address follow-up carryover", () => {
const sessionId = `asst-address-followup-${Date.now()}`;
const first = await service.handleMessage({
session_id: sessionId,
user_message: "какие есть доки по свк с 2020 по 2025 год",
user_message: "покажи документы по свк за 2020",
useMock: true
} as any);
expect(first.ok).toBe(true);
@@ -121,7 +118,7 @@ describe("assistant address follow-up carryover", () => {
expect(second.debug?.answer_grounding_check?.reasons).toContain("address_followup_context_applied");
expect(calls).toHaveLength(2);
expect(calls[0].message).toBe(акие есть доки по свк с 2020 по 2025 год");
expect(calls[0].message.toLowerCase()).toContain("свк");
expect(calls[1].message).toBe("а за все время?");
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
@@ -494,6 +494,118 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_counterparty_year_rewrite");
});
it("rewrites shorthand bank/contract slang phrase to bank operations by contract", 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-bank-contract-slang",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "покажи банк опер по дог 15/24 пж",
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: []
},
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-bank-contract-slang-${Date.now()}`,
user_message: "покажи банк опер по дог 15/24 пж",
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("банковские операции по договору 15/24");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("bank_operations_contract_rewrite");
});
it("keeps loose all-time colloquial lookup in address lane without forcing rewrite", 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-loose-all-time",
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: []
},
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-loose-all-time-${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(response.debug?.llm_decomposition_applied).toBe(false);
expect(response.debug?.llm_decomposition_reason).toBe("not_address_like");
expect(response.debug?.fallback_rule_hit).toBeNull();
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
});
it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { OpenAIResponsesClient } from "../src/services/openaiResponsesClient";
describe("openai local responses fallback", () => {
const originalFetch = global.fetch;
afterEach(() => {
vi.restoreAllMocks();
global.fetch = originalFetch;
});
it("falls back to /chat/completions when /responses payload is parseable JSON but has no output_text", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "resp-1", output: [], usage: { prompt_tokens: 4, completion_tokens: 3 } }), {
status: 200,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
id: "chat-1",
choices: [{ message: { content: "{\"schema_version\":\"normalized_query_v2_0_2\"}" } }],
usage: { prompt_tokens: 7, completion_tokens: 5, total_tokens: 12 }
}),
{
status: 200,
headers: { "content-type": "application/json" }
}
)
);
global.fetch = fetchMock as unknown as typeof fetch;
const client = new OpenAIResponsesClient();
const response = await client.normalize(
{
llmProvider: "local",
apiKey: "",
model: "qwen2.5-14b-instruct-1m",
baseUrl: "http://127.0.0.1:1234",
temperature: 0
},
{
systemPrompt: "system",
developerPrompt: "developer",
domainPrompt: "domain",
userQuestion: "question",
schemaVersion: "v2_0_2"
}
);
expect(response.outputText).toContain("\"schema_version\":\"normalized_query_v2_0_2\"");
expect(response.usage.total_tokens).toBe(12);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toContain("/responses");
expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toContain("/chat/completions");
});
it("retries alternative local base when endpoint mismatch payload is returned for chat completions", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { message: "Unexpected endpoint or method. (POST /responses)" } }), {
status: 404,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { message: "Unexpected endpoint or method. (POST /responses)" } }), {
status: 404,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "Unexpected endpoint or method. (POST /chat/completions)" }), {
status: 200,
headers: { "content-type": "application/json" }
})
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
id: "chat-v1",
choices: [{ message: { content: "{\"schema_version\":\"normalized_query_v2_0_2\"}" } }],
usage: { prompt_tokens: 10, completion_tokens: 7, total_tokens: 17 }
}),
{
status: 200,
headers: { "content-type": "application/json" }
}
)
);
global.fetch = fetchMock as unknown as typeof fetch;
const client = new OpenAIResponsesClient();
const response = await client.normalize(
{
llmProvider: "local",
apiKey: "",
model: "qwen2.5-14b-instruct-1m",
baseUrl: "http://127.0.0.1:1234",
temperature: 0
},
{
systemPrompt: "system",
developerPrompt: "developer",
domainPrompt: "domain",
userQuestion: "question",
schemaVersion: "v2_0_2"
}
);
expect(response.outputText).toContain("\"schema_version\":\"normalized_query_v2_0_2\"");
expect(response.usage.total_tokens).toBe(17);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("http://127.0.0.1:1234/responses");
expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toBe("http://127.0.0.1:1234/v1/responses");
expect(String(fetchMock.mock.calls[2]?.[0] ?? "")).toBe("http://127.0.0.1:1234/chat/completions");
expect(String(fetchMock.mock.calls[3]?.[0] ?? "")).toBe("http://127.0.0.1:1234/v1/chat/completions");
});
});
@@ -0,0 +1,25 @@
const { resolveAddressIntent } = require('../dist/services/addressIntentResolver.js');
const { extractAddressFilters } = require('../dist/services/addressFilterExtractor.js');
const { selectAddressRecipe, buildAddressRecipePlan } = require('../dist/services/addressRecipeCatalog.js');
const { executeAddressMcpQuery } = require('../dist/services/addressMcpClient.js');
(async () => {
const q = 'Покажи документы по договору 15/24';
const intentObj = resolveAddressIntent(q);
const filtersObj = extractAddressFilters(q, intentObj.intent);
const sel = selectAddressRecipe(intentObj.intent, filtersObj.extracted_filters);
console.log('intent_obj=', intentObj);
console.log('filters_obj=', filtersObj);
console.log('selection=', sel);
if (!sel.selected_recipe) return;
const plan = buildAddressRecipePlan(sel.selected_recipe, filtersObj.extracted_filters);
const mcp = await executeAddressMcpQuery({ query: plan.query, limit: plan.limit });
const rows = Array.isArray(mcp.raw_rows) ? mcp.raw_rows : [];
const contractNeedle = String(filtersObj.extracted_filters.contract || '').toLowerCase();
const byAnchor = rows.filter((r) => JSON.stringify(r).toLowerCase().includes(contractNeedle));
console.log('query_limit=', plan.limit);
console.log('raw_rows=', rows.length);
console.log('by_anchor=', byAnchor.length);
console.log('sample_registrators=', byAnchor.slice(0,10).map((r)=>String(r['Регистратор'] ?? r['Registrator'] ?? '')));
console.log('sample_rows=', byAnchor.slice(0,5));
})();