АДРЕСНЫЙ РЕЖИМ - L0-стабилизация address: LLM-first + deterministic fallback + tool-gate + trace

This commit is contained in:
dctouch 2026-04-01 22:34:58 +03:00
parent b2d32f869c
commit 2d95ce6332
2 changed files with 454 additions and 7 deletions

View File

@ -1761,6 +1761,10 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
llm_decomposition_trace_id: llmMeta?.traceId ?? null, llm_decomposition_trace_id: llmMeta?.traceId ?? null,
llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null, llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? null,
llm_decomposition_reason: llmMeta?.reason ?? null, llm_decomposition_reason: llmMeta?.reason ?? null,
fallback_rule_hit: llmMeta?.fallbackRuleHit ?? null,
sanitized_user_message: llmMeta?.sanitizedUserMessage ?? null,
tool_gate_decision: llmMeta?.toolGateDecision ?? null,
tool_gate_reason: llmMeta?.toolGateReason ?? null,
answer_structure_v11: null, answer_structure_v11: null,
investigation_state_snapshot: null, investigation_state_snapshot: null,
normalized: null, normalized: null,
@ -1839,6 +1843,260 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"ёпт", "ёпт",
"бля" "бля"
]); ]);
const ADDRESS_FALLBACK_STRIP_TOKENS = new Set([
"бля",
"блять",
"blya",
"blyat",
"епт",
"ёпт",
"епта",
"нах",
"нахуй",
"плс",
"pls",
"пж",
"пжлст",
"пожалуйста",
"please"
]);
const ADDRESS_MONTH_ALIAS_MAP = {
янв: "01",
январ: "01",
january: "01",
jan: "01",
фев: "02",
феврал: "02",
february: "02",
feb: "02",
мар: "03",
март: "03",
march: "03",
apr: "04",
апр: "04",
апрел: "04",
april: "04",
май: "05",
ма: "05",
may: "05",
июн: "06",
июнь: "06",
june: "06",
jun: "06",
июл: "07",
июль: "07",
july: "07",
jul: "07",
авг: "08",
август: "08",
august: "08",
aug: "08",
сен: "09",
сент: "09",
сентябр: "09",
september: "09",
sep: "09",
окт: "10",
октябр: "10",
october: "10",
oct: "10",
ноя: "11",
ноябр: "11",
november: "11",
nov: "11",
дек: "12",
декабр: "12",
december: "12",
dec: "12"
};
const ADDRESS_DOCS_SIGNAL_PATTERN = /(?:док|доки|документ|документы|документов|docs?|documents?|bank|выписк|плат[её]ж|оплат|поступлен|списан|операц)/i;
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) {
const source = String(token ?? "").trim().toLowerCase();
if (!source) {
return null;
}
const direct = ADDRESS_MONTH_ALIAS_MAP[source];
if (direct) {
return direct;
}
for (const [key, value] of Object.entries(ADDRESS_MONTH_ALIAS_MAP)) {
if (source.startsWith(key)) {
return value;
}
}
return null;
}
function normalizeAddressShortYearMentions(text) {
return String(text ?? "").replace(/(^|[^0-9])(\d{2})\s*(?:г(?:од|ода)?|г|год|year|god)(?=$|[^a-zа-яё0-9])/giu, (_full, prefix, shortYear) => {
const normalized = Number(shortYear);
if (!Number.isFinite(normalized) || normalized < 0 || normalized > 99) {
return _full;
}
return `${prefix}${String(2000 + normalized)} год`;
});
}
function sanitizeAddressMessageForFallback(userMessage) {
const repaired = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")));
if (!repaired) {
return "";
}
let sanitized = repaired.toLowerCase();
sanitized = sanitized
.replace(/\bpokezh\b/giu, "покажи")
.replace(/\bpokazh(?:i)?\b/giu, "покажи")
.replace(/\bpokaji\b/giu, "покажи")
.replace(/\bdok(?:i|y)?\b/giu, "доки")
.replace(/\bdocuments?\b/giu, "документы")
.replace(/\bdocs?\b/giu, "документы")
.replace(/\bschet(?:u)?\b/giu, "счет")
.replace(/\bsaldo\b/giu, "сальдо")
.replace(/\bgod\b/giu, "год");
sanitized = normalizeAddressShortYearMentions(sanitized);
const tokens = sanitized
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
const filteredTokens = tokens.filter((token) => {
const normalizedToken = token.replace(/^[^a-zа-яё0-9]+|[^a-zа-яё0-9]+$/giu, "");
if (!normalizedToken) {
return true;
}
return !ADDRESS_FALLBACK_STRIP_TOKENS.has(normalizedToken);
});
const compact = compactWhitespace(filteredTokens.join(" "));
return compact || compactWhitespace(repaired.toLowerCase());
}
function extractAddressFallbackYear(text) {
const source = String(text ?? "");
const fullYearMatch = source.match(/\b(20\d{2})\b/);
if (fullYearMatch) {
return fullYearMatch[1];
}
const shortYearMatch = source.match(/(?:^|[^0-9])(\d{2})\s*(?:г(?:од|ода)?|г|год|year|god)(?=$|[^a-zа-яё0-9])/iu);
if (!shortYearMatch) {
return null;
}
const shortYear = Number(shortYearMatch[1]);
if (!Number.isFinite(shortYear) || shortYear < 0 || shortYear > 99) {
return null;
}
return String(2000 + shortYear);
}
function extractAddressFallbackMonthYear(text) {
const source = String(text ?? "");
const numericYearMonth = source.match(/\b(20\d{2})[./-](0?[1-9]|1[0-2])\b/);
if (numericYearMonth) {
const year = numericYearMonth[1];
const month = String(Number(numericYearMonth[2])).padStart(2, "0");
return `${year}-${month}`;
}
const numericMonthYear = source.match(/\b(0?[1-9]|1[0-2])[./-](20\d{2})\b/);
if (numericMonthYear) {
const month = String(Number(numericMonthYear[1])).padStart(2, "0");
const year = numericMonthYear[2];
return `${year}-${month}`;
}
const namedMonthYear = source.match(/\b([a-zа-яё]+)\s+(20\d{2})\b/iu);
if (namedMonthYear) {
const month = normalizeAddressMonthAliasToken(namedMonthYear[1]);
if (month) {
return `${namedMonthYear[2]}-${month}`;
}
}
const yearNamedMonth = source.match(/\b(20\d{2})\s+([a-zа-яё]+)\b/iu);
if (yearNamedMonth) {
const month = normalizeAddressMonthAliasToken(yearNamedMonth[2]);
if (month) {
return `${yearNamedMonth[1]}-${month}`;
}
}
return null;
}
function pickAddressFallbackCounterpartyToken(text) {
const candidates = extractAddressAnchorTokens(text);
for (const token of candidates) {
const normalized = String(token ?? "").toLowerCase();
if (!normalized || /^\d{2}(?:\.\d{1,2})?$/.test(normalized)) {
continue;
}
if (/^(?:19|20)\d{2}$/.test(normalized)) {
continue;
}
if (/^(?:янв|фев|мар|апр|май|июн|июл|авг|сен|сент|окт|ноя|дек|january|february|march|april|may|june|july|august|september|october|november|december)/i.test(normalized)) {
continue;
}
return token;
}
return null;
}
function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage) {
const sourceRaw = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")));
const source = compactWhitespace(String(sanitizedUserMessage ?? sourceRaw).toLowerCase());
if (!source) {
return null;
}
const monthYear = extractAddressFallbackMonthYear(source);
const year = extractAddressFallbackYear(source);
const allTime = ADDRESS_ALL_TIME_PATTERN.test(source);
const accountMatch = source.match(/\b(\d{2}(?:[.,]\d{1,2})?)\b/);
const account = accountMatch ? String(accountMatch[1]).replace(",", ".") : null;
const docsSignal = ADDRESS_DOCS_SIGNAL_PATTERN.test(source);
const balanceSignal = ADDRESS_BALANCE_SIGNAL_PATTERN.test(source);
if (balanceSignal && account) {
let periodClause = "";
let rule = "balance_account_rewrite";
if (monthYear) {
periodClause = ` на ${monthYear}`;
rule = "balance_month_period_rewrite";
}
else if (year) {
periodClause = ` на ${year}-12-31`;
rule = "balance_year_period_rewrite";
}
const candidate = compactWhitespace(`остаток по счету ${account}${periodClause}`);
if (candidate && candidate !== sourceRaw.toLowerCase()) {
return {
candidate,
rule
};
}
}
if (docsSignal) {
const counterparty = pickAddressFallbackCounterpartyToken(source);
if (counterparty) {
let periodClause = "";
let rule = "documents_counterparty_rewrite";
if (allTime) {
periodClause = " за все время";
rule = "documents_counterparty_all_time_rewrite";
}
else if (monthYear) {
periodClause = ` за ${monthYear}`;
rule = "documents_counterparty_month_rewrite";
}
else if (year) {
periodClause = ` за ${year} год`;
rule = "documents_counterparty_year_rewrite";
}
const candidate = compactWhitespace(`документы по контрагенту ${counterparty}${periodClause}`);
if (candidate && candidate !== sourceRaw.toLowerCase()) {
return {
candidate,
rule
};
}
}
}
if (source !== sourceRaw.toLowerCase() && isAddressLlmPreDecomposeCandidate(source)) {
return {
candidate: source,
rule: "noise_cleanup"
};
}
return null;
}
function textMojibakeScoreForAddress(value) { function textMojibakeScoreForAddress(value) {
const source = String(value ?? ""); const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
@ -2162,21 +2420,56 @@ function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) {
} }
async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) { async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) {
const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null; const provider = payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null;
const sanitizedUserMessage = sanitizeAddressMessageForFallback(userMessage);
const fallbackCandidate = resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage);
const hasAddressSignal = isAddressLlmPreDecomposeCandidate(userMessage) || isAddressLlmPreDecomposeCandidate(sanitizedUserMessage);
const baseMeta = { const baseMeta = {
attempted: false, attempted: false,
applied: false, applied: false,
provider, provider,
traceId: null, traceId: null,
effectiveMessage: userMessage, effectiveMessage: userMessage,
reason: "not_attempted" reason: "not_attempted",
fallbackRuleHit: null,
sanitizedUserMessage,
toolGateDecision: null,
toolGateReason: null
}; };
if (Boolean(payload?.useMock)) { if (Boolean(payload?.useMock)) {
if (fallbackCandidate) {
const fallbackCompact = compactWhitespace(String(fallbackCandidate.candidate ?? "").toLowerCase());
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
...baseMeta,
applied: true,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_without_llm",
fallbackRuleHit: fallbackCandidate.rule
};
}
}
return { return {
...baseMeta, ...baseMeta,
reason: "skipped_in_mock" reason: "skipped_in_mock"
}; };
} }
if (!isAddressLlmPreDecomposeCandidate(userMessage)) { if (!hasAddressSignal) {
if (fallbackCandidate) {
const fallbackCompact = compactWhitespace(String(fallbackCandidate.candidate ?? "").toLowerCase());
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
...baseMeta,
applied: true,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_without_llm",
fallbackRuleHit: fallbackCandidate.rule
};
}
}
return { return {
...baseMeta, ...baseMeta,
reason: "not_address_like" reason: "not_address_like"
@ -2201,6 +2494,22 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const candidateFromRaw = candidateFromNormalized ? null : extractAddressQuestionFromRawNormalizerOutput(normalized?.raw_model_output); const candidateFromRaw = candidateFromNormalized ? null : extractAddressQuestionFromRawNormalizerOutput(normalized?.raw_model_output);
const candidate = candidateFromNormalized ?? candidateFromRaw; const candidate = candidateFromNormalized ?? candidateFromRaw;
if (!candidate) { if (!candidate) {
if (fallbackCandidate) {
const fallbackCompact = compactWhitespace(String(fallbackCandidate.candidate ?? "").toLowerCase());
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
...baseMeta,
attempted: true,
applied: true,
traceId: normalized?.trace_id ?? null,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_after_llm",
fallbackRuleHit: fallbackCandidate.rule
};
}
}
return { return {
...baseMeta, ...baseMeta,
attempted: true, attempted: true,
@ -2229,10 +2538,27 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
provider, provider,
traceId: normalized?.trace_id ?? null, traceId: normalized?.trace_id ?? null,
effectiveMessage: applied ? candidate : userMessage, effectiveMessage: applied ? candidate : userMessage,
reason reason,
fallbackRuleHit: null,
sanitizedUserMessage
}; };
} }
catch (error) { catch (error) {
if (fallbackCandidate) {
const fallbackCompact = compactWhitespace(String(fallbackCandidate.candidate ?? "").toLowerCase());
const sourceCompact = compactWhitespace(String(userMessage ?? "").toLowerCase());
const fallbackApplied = fallbackCompact.length > 0 && fallbackCompact !== sourceCompact;
if (fallbackApplied) {
return {
...baseMeta,
attempted: true,
applied: true,
effectiveMessage: fallbackCandidate.candidate,
reason: "fallback_rule_applied_after_llm_error",
fallbackRuleHit: fallbackCandidate.rule
};
}
}
return { return {
...baseMeta, ...baseMeta,
attempted: true, attempted: true,
@ -2240,6 +2566,28 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
}; };
} }
} }
function resolveAddressToolGateDecision(addressInputMessage, followupContext) {
const hasMessageSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) || hasAccountingSignal(addressInputMessage);
if (hasMessageSignal) {
return {
runAddressLane: true,
decision: "run_address_lane",
reason: "address_signal_detected"
};
}
if (followupContext) {
return {
runAddressLane: true,
decision: "run_address_lane",
reason: "followup_context_detected"
};
}
return {
runAddressLane: false,
decision: "skip_address_lane",
reason: "no_address_signal_after_l0"
};
}
export class AssistantService { export class AssistantService {
normalizerService; normalizerService;
sessions; sessions;
@ -2309,6 +2657,10 @@ export class AssistantService {
address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null, address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null,
address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null, address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? null,
address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null, address_llm_predecompose_reason: llmPreDecomposeMeta?.reason ?? null,
address_fallback_rule_hit: llmPreDecomposeMeta?.fallbackRuleHit ?? null,
address_sanitized_user_message: llmPreDecomposeMeta?.sanitizedUserMessage ?? null,
address_tool_gate_decision: llmPreDecomposeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: llmPreDecomposeMeta?.toolGateReason ?? null,
detected_mode: addressLane.debug.detected_mode, detected_mode: addressLane.debug.detected_mode,
query_shape: addressLane.debug.query_shape, query_shape: addressLane.debug.query_shape,
detected_intent: addressLane.debug.detected_intent, detected_intent: addressLane.debug.detected_intent,
@ -2362,31 +2714,64 @@ export class AssistantService {
provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null, provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null,
traceId: null, traceId: null,
effectiveMessage: userMessage, effectiveMessage: userMessage,
reason: "disabled_by_feature_flag" reason: "disabled_by_feature_flag",
fallbackRuleHit: null,
sanitizedUserMessage: sanitizeAddressMessageForFallback(userMessage),
toolGateDecision: null,
toolGateReason: null
}; };
const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage; const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage;
const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items); const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items);
const toolGate = resolveAddressToolGateDecision(addressInputMessage, carryover?.followupContext ?? null);
const addressRuntimeMeta = {
...addressPreDecompose,
toolGateDecision: toolGate.decision,
toolGateReason: toolGate.reason
};
if (!toolGate.runAddressLane) {
(0, log_1.logJson)({
timestamp: new Date().toISOString(),
level: "info",
service: "assistant_loop",
message: "assistant_address_tool_gate_skip",
sessionId,
details: {
session_id: sessionId,
user_message: userMessage,
effective_address_user_message: addressInputMessage,
address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted),
address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied),
address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null,
address_fallback_rule_hit: addressRuntimeMeta?.fallbackRuleHit ?? null,
address_sanitized_user_message: addressRuntimeMeta?.sanitizedUserMessage ?? null,
address_tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null,
address_tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null
}
});
}
if (toolGate.runAddressLane) {
const shouldPreferContextualLane = Boolean(carryover?.followupContext); const shouldPreferContextualLane = Boolean(carryover?.followupContext);
if (shouldPreferContextualLane) { if (shouldPreferContextualLane) {
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, { const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
followupContext: carryover.followupContext followupContext: carryover.followupContext
}); });
if (contextualAddressLane?.handled) { if (contextualAddressLane?.handled) {
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose); return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta);
} }
} }
const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage); const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage);
if (primaryAddressLane?.handled) { if (primaryAddressLane?.handled) {
return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose); return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressRuntimeMeta);
} }
if (!shouldPreferContextualLane && carryover?.followupContext) { if (!shouldPreferContextualLane && carryover?.followupContext) {
const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, { const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, {
followupContext: carryover.followupContext followupContext: carryover.followupContext
}); });
if (contextualAddressLane?.handled) { if (contextualAddressLane?.handled) {
return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose); return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta);
} }
} }
}
} }
const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 && const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 &&
config_1.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 && config_1.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 &&

View File

@ -152,5 +152,67 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.llm_decomposition_attempted).toBe(true); expect(response.debug?.llm_decomposition_attempted).toBe(true);
expect(response.debug?.llm_decomposition_applied).toBe(true); expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_effective_message).toBe("svk doki za 20 god pokezh"); expect(response.debug?.llm_decomposition_effective_message).toBe("svk doki za 20 god pokezh");
expect(response.debug?.fallback_rule_hit).toBeNull();
expect(response.debug?.sanitized_user_message).toBeTypeOf("string");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
});
it("applies deterministic fallback rule when llm fragment is unusable", 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-2",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "свк доки за 20год покеж плс",
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-fallback-${Date.now()}`,
user_message: "свк доки за 20год покеж плс",
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("документы по контрагенту свк за 2020 год");
expect(response.debug?.llm_decomposition_attempted).toBe(true);
expect(response.debug?.llm_decomposition_applied).toBe(true);
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");
expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite");
expect(response.debug?.sanitized_user_message).toContain("свк");
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
}); });
}); });