АДРЕСНЫЙ РЕЖИМ - L0-стабилизация address: LLM-first + deterministic fallback + tool-gate + trace
This commit is contained in:
parent
b2d32f869c
commit
2d95ce6332
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue