АДРЕСНЫЙ РЕЖИМ - 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_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 &&
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue