From 2d95ce633217bf8be833edfe39dbc8a6a9d02507 Mon Sep 17 00:00:00 2001 From: dctouch Date: Wed, 1 Apr 2026 22:34:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=94=D0=A0=D0=95=D0=A1=D0=9D=D0=AB?= =?UTF-8?q?=D0=99=20=D0=A0=D0=95=D0=96=D0=98=D0=9C=20-=20L0-=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?address:=20LLM-first=20+=20deterministic=20fallback=20+=20tool-?= =?UTF-8?q?gate=20+=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/services/assistantService.ts | 399 +++++++++++++++++- .../assistantAddressLlmPredecompose.test.ts | 62 +++ 2 files changed, 454 insertions(+), 7 deletions(-) diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 4e5883a..cc7616a 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -1761,6 +1761,10 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) { llm_decomposition_trace_id: llmMeta?.traceId ?? null, llm_decomposition_effective_message: llmMeta?.effectiveMessage ?? 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, investigation_state_snapshot: 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) { const source = String(value ?? ""); const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; @@ -2162,21 +2420,56 @@ function extractAddressQuestionFromRawNormalizerOutput(rawModelOutput) { } async function runAddressLlmPreDecompose(normalizerService, payload, userMessage) { 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 = { attempted: false, applied: false, provider, traceId: null, effectiveMessage: userMessage, - reason: "not_attempted" + reason: "not_attempted", + fallbackRuleHit: null, + sanitizedUserMessage, + toolGateDecision: null, + toolGateReason: null }; 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 { ...baseMeta, 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 { ...baseMeta, reason: "not_address_like" @@ -2201,6 +2494,22 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const candidateFromRaw = candidateFromNormalized ? null : extractAddressQuestionFromRawNormalizerOutput(normalized?.raw_model_output); const candidate = candidateFromNormalized ?? candidateFromRaw; 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 { ...baseMeta, attempted: true, @@ -2229,10 +2538,27 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage provider, traceId: normalized?.trace_id ?? null, effectiveMessage: applied ? candidate : userMessage, - reason + reason, + fallbackRuleHit: null, + sanitizedUserMessage }; } 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 { ...baseMeta, 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 { normalizerService; sessions; @@ -2309,6 +2657,10 @@ export class AssistantService { address_llm_predecompose_provider: llmPreDecomposeMeta?.provider ?? null, address_llm_predecompose_trace_id: llmPreDecomposeMeta?.traceId ?? 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, query_shape: addressLane.debug.query_shape, detected_intent: addressLane.debug.detected_intent, @@ -2362,31 +2714,64 @@ export class AssistantService { provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null, traceId: null, 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 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); if (shouldPreferContextualLane) { const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, { followupContext: carryover.followupContext }); if (contextualAddressLane?.handled) { - return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose); + return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta); } } const primaryAddressLane = await this.addressQueryService.tryHandle(addressInputMessage); if (primaryAddressLane?.handled) { - return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressPreDecompose); + return finalizeAddressLaneResponse(primaryAddressLane, addressInputMessage, null, addressRuntimeMeta); } if (!shouldPreferContextualLane && carryover?.followupContext) { const contextualAddressLane = await this.addressQueryService.tryHandle(addressInputMessage, { followupContext: carryover.followupContext }); if (contextualAddressLane?.handled) { - return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressPreDecompose); + return finalizeAddressLaneResponse(contextualAddressLane, addressInputMessage, carryover, addressRuntimeMeta); } } + } } const followupBinding = config_1.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 && config_1.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 && diff --git a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts index 40d7a57..a258ad1 100644 --- a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts @@ -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_applied).toBe(true); 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"); }); });