diff --git a/llm_normalizer/backend/dist/routes/autoRuns.js b/llm_normalizer/backend/dist/routes/autoRuns.js index b73bb09..73c3b44 100644 --- a/llm_normalizer/backend/dist/routes/autoRuns.js +++ b/llm_normalizer/backend/dist/routes/autoRuns.js @@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.__autoRunsQuestionTestUtils = void 0; exports.buildAutoRunsRouter = buildAutoRunsRouter; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); @@ -1148,6 +1149,61 @@ function sanitizeGeneratedQuestion(value) { .replace(/\s+/g, " ") .trim(); } +const AUTOGEN_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu; +const AUTOGEN_QUESTION_TAIL_PATTERNS = [ + /^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu +]; +function stripAutogenQuestionSuffix(value) { + return sanitizeGeneratedQuestion(value).replace(/[?!.:,;]+$/u, "").trim(); +} +function isAutogenQuestionPlaceholder(value) { + const core = stripAutogenQuestionSuffix(value).toLowerCase(); + return core.length > 0 && AUTOGEN_QUESTION_PLACEHOLDER_PATTERN.test(core); +} +function isLikelyAutogenQuestionTail(value) { + const core = stripAutogenQuestionSuffix(value).toLowerCase(); + if (!core) { + return false; + } + if (isAutogenQuestionPlaceholder(core)) { + return true; + } + return AUTOGEN_QUESTION_TAIL_PATTERNS.some((pattern) => pattern.test(core)); +} +function mergeAutogenQuestionTail(baseQuestion, tail) { + const base = stripAutogenQuestionSuffix(baseQuestion); + const suffix = stripAutogenQuestionSuffix(tail); + if (!base) { + return suffix ? `${suffix}?` : ""; + } + if (!suffix) { + return `${base}?`; + } + return `${base} ${suffix}?` + .replace(/\s+/g, " ") + .trim(); +} +function normalizeAutogenQuestionCandidates(candidates) { + const normalized = []; + for (const candidate of candidates) { + const question = sanitizeGeneratedQuestion(candidate); + if (!question) { + continue; + } + if (isAutogenQuestionPlaceholder(question)) { + continue; + } + if (isLikelyAutogenQuestionTail(question) && normalized.length > 0) { + const merged = mergeAutogenQuestionTail(normalized[normalized.length - 1], question); + if (merged) { + normalized[normalized.length - 1] = merged; + } + continue; + } + normalized.push(question); + } + return normalized.filter((item) => item.length > 0); +} function splitQuestionCandidates(rawText) { const normalized = repairAutogenMojibake(rawText).replace(/\r/g, "\n").trim(); if (!normalized) @@ -1159,27 +1215,30 @@ function splitQuestionCandidates(rawText) { .map((line) => sanitizeGeneratedQuestion(line)) .filter((line) => line.length > 0); if (byLines.length > 1) { - return byLines; + return normalizeAutogenQuestionCandidates(byLines); } const questionMarkCount = (unescaped.match(/\?/g) ?? []).length; if (questionMarkCount > 1) { - const byQuestion = unescaped - .split("?") - .map((chunk) => sanitizeGeneratedQuestion(chunk)) - .filter((chunk) => chunk.length > 0) - .map((chunk) => (chunk.endsWith("?") ? chunk : `${chunk}?`)); - if (byQuestion.length > 1) { - return byQuestion; + const questionChunks = Array.from(unescaped.matchAll(/[^?]+(?:\?|$)/g)) + .map((match) => sanitizeGeneratedQuestion(match[0])) + .filter((chunk) => chunk.length > 0); + if (questionChunks.length > 1) { + const canSafelySplit = questionChunks.every((chunk) => !isAutogenQuestionPlaceholder(chunk) && + !isLikelyAutogenQuestionTail(chunk) && + sanitizeGeneratedQuestion(chunk).length >= 18); + if (canSafelySplit) { + return normalizeAutogenQuestionCandidates(questionChunks.map((chunk) => (chunk.endsWith("?") ? chunk : `${chunk}?`))); + } } } const quoted = Array.from(unescaped.matchAll(/"([^"\n]{6,}?)"/g)) .map((match) => sanitizeGeneratedQuestion(match[1])) .filter((line) => line.length > 0); if (quoted.length > 1) { - return quoted; + return normalizeAutogenQuestionCandidates(quoted); } const cleaned = sanitizeGeneratedQuestion(unescaped); - return cleaned ? [cleaned] : []; + return cleaned ? normalizeAutogenQuestionCandidates([cleaned]) : []; } function parseAutogenOutputJson(rawText) { const cleaned = repairAutogenMojibake(rawText) @@ -1225,7 +1284,8 @@ function collectQuestionsFromCandidate(value, depth = 0) { return []; } if (Array.isArray(value)) { - return value.flatMap((item) => collectQuestionsFromCandidate(item, depth + 1)); + const expanded = value.flatMap((item) => collectQuestionsFromCandidate(item, depth + 1)); + return normalizeAutogenQuestionCandidates(expanded); } if (typeof value === "string") { const text = value.trim(); @@ -1271,6 +1331,10 @@ function extractQuestionsFromAutogenOutput(rawText) { } return collectQuestionsFromCandidate(rawText); } +exports.__autoRunsQuestionTestUtils = { + splitQuestionCandidates, + extractQuestionsFromAutogenOutput +}; async function generateQwenSeedQuestionsLive(input) { const seedExamples = collectCanonicalQuestions(40); const fallbackExamples = fallbackDomainTemplates(input.domain); diff --git a/llm_normalizer/backend/dist/routes/eval.js b/llm_normalizer/backend/dist/routes/eval.js index 8ef60cf..0f5f380 100644 --- a/llm_normalizer/backend/dist/routes/eval.js +++ b/llm_normalizer/backend/dist/routes/eval.js @@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.__evalRouteTestUtils = void 0; exports.buildEvalRouter = buildEvalRouter; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); @@ -35,6 +36,61 @@ function normalizeQuestionChunk(value) { .replace(/\s+/g, " ") .trim(); } +const RUNTIME_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu; +const RUNTIME_QUESTION_TAIL_PATTERNS = [ + /^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu +]; +function stripQuestionSuffix(value) { + return normalizeQuestionChunk(value).replace(/[?!.:,;]+$/u, "").trim(); +} +function isRuntimeQuestionPlaceholder(value) { + const core = stripQuestionSuffix(value).toLowerCase(); + return core.length > 0 && RUNTIME_QUESTION_PLACEHOLDER_PATTERN.test(core); +} +function isLikelyRuntimeQuestionTail(value) { + const core = stripQuestionSuffix(value).toLowerCase(); + if (!core) { + return false; + } + if (isRuntimeQuestionPlaceholder(core)) { + return true; + } + return RUNTIME_QUESTION_TAIL_PATTERNS.some((pattern) => pattern.test(core)); +} +function mergeRuntimeQuestionTail(baseQuestion, tail) { + const base = stripQuestionSuffix(baseQuestion); + const suffix = stripQuestionSuffix(tail); + if (!base) { + return suffix ? `${suffix}?` : ""; + } + if (!suffix) { + return `${base}?`; + } + return `${base} ${suffix}?` + .replace(/\s+/g, " ") + .trim(); +} +function normalizeRuntimeQuestionList(items) { + const normalized = []; + for (const item of items) { + const chunk = normalizeQuestionChunk(item); + if (!chunk) { + continue; + } + if (isRuntimeQuestionPlaceholder(chunk)) { + continue; + } + if (isLikelyRuntimeQuestionTail(chunk) && normalized.length > 0) { + const merged = mergeRuntimeQuestionTail(normalized[normalized.length - 1], chunk); + if (merged) { + normalized[normalized.length - 1] = merged; + } + continue; + } + normalized.push(chunk); + } + return normalized.filter((item) => item.length > 0); +} function splitQuestionCandidate(raw) { const normalized = String(raw ?? "").replace(/\r/g, "\n").trim(); if (!normalized) { @@ -47,18 +103,30 @@ function splitQuestionCandidate(raw) { const source = byLines.length > 1 ? byLines : [normalized]; const chunks = []; for (const line of source) { + const normalizedLine = normalizeQuestionChunk(line); + if (!normalizedLine || isRuntimeQuestionPlaceholder(normalizedLine)) { + continue; + } const questionLike = Array.from(line.matchAll(/[^?]+(?:\?|$)/g)) .map((match) => normalizeQuestionChunk(match[0])) .filter((item) => item.length > 0); if (questionLike.length > 1) { - for (const item of questionLike) { - chunks.push(item.endsWith("?") ? item : `${item}?`); + const canSafelySplit = questionLike.every((item) => !isRuntimeQuestionPlaceholder(item) && + !isLikelyRuntimeQuestionTail(item) && + normalizeQuestionChunk(item).length >= 18); + if (canSafelySplit) { + for (const item of questionLike) { + chunks.push(item.endsWith("?") ? item : `${item}?`); + } + } + else { + chunks.push(normalizedLine); } continue; } - chunks.push(normalizeQuestionChunk(line)); + chunks.push(normalizedLine); } - return chunks.filter((item) => item.length > 0); + return normalizeRuntimeQuestionList(chunks); } function normalizeRuntimeQuestions(value) { const raw = toArray(value) @@ -67,7 +135,7 @@ function normalizeRuntimeQuestions(value) { if (raw.length === 0) { return []; } - const expanded = raw.flatMap((item) => splitQuestionCandidate(item)); + const expanded = normalizeRuntimeQuestionList(raw.flatMap((item) => splitQuestionCandidate(item))); const deduped = []; const seen = new Set(); for (const item of expanded) { @@ -81,6 +149,10 @@ function normalizeRuntimeQuestions(value) { } return deduped; } +exports.__evalRouteTestUtils = { + splitQuestionCandidate, + normalizeRuntimeQuestions +}; function normalizeCaseIds(value) { if (!Array.isArray(value)) { return undefined; diff --git a/llm_normalizer/backend/dist/services/answerComposer.js b/llm_normalizer/backend/dist/services/answerComposer.js index 63c40a5..796b8d6 100644 --- a/llm_normalizer/backend/dist/services/answerComposer.js +++ b/llm_normalizer/backend/dist/services/answerComposer.js @@ -1569,6 +1569,112 @@ function buildAnswerSummary(mode) { return "Недостаточно опоры для обоснованного ответа."; return "Не удалось собрать обоснованный ответ по текущему запросу."; } +const BOUNDARY_CAPABILITY_SUGGESTIONS = [ + { + key: "settlements_60_62", + label: "Взаиморасчеты 60/62", + helpText: "найти хвосты, незакрытые оплаты и рисковые связки по контрагентам.", + signals: /(контраг|долг|сальдо|взаиморасчет|оплат|аванс|покупат|поставщ|банк|выписк|\b60\b|\b62\b|\b76\b)/iu + }, + { + key: "vat_document_register_book", + label: "НДС 19/68", + helpText: "проверить цепочку документ -> счет-фактура -> регистр -> книга.", + signals: /(ндс|сч[её]т[-\s]?фактур|регистр|книга\s+покуп|книга\s+продаж|декларац|\b19\b|\b68\b)/iu + }, + { + key: "month_close_costs_20_44", + label: "Закрытие месяца 20/44", + helpText: "проверить распределение затрат и остатки после регламентных операций.", + signals: /(закрыти[ея]|месяц|затрат|распределени|рбп|аморт|основн|ос\b|\b20\b|\b25\b|\b26\b|\b44\b)/iu + } +]; +function formatNarrativeDomainLabel(domain) { + if (domain === "settlements_60_62") { + return "взаиморасчетов 60/62"; + } + if (domain === "vat_document_register_book") { + return "НДС-контура 19/68"; + } + if (domain === "month_close_costs_20_44") { + return "закрытия месяца (20/44)"; + } + return "доступного учетного контура"; +} +function pickBoundaryCapabilityLines(userMessage, limit = 3) { + const text = String(userMessage ?? "").toLowerCase(); + const scored = BOUNDARY_CAPABILITY_SUGGESTIONS.map((item, index) => ({ + item, + score: (text.match(item.signals) ?? []).length, + order: index + })); + const ranked = scored + .slice() + .sort((left, right) => right.score - left.score || left.order - right.order) + .map((entry) => entry.item); + const selected = ranked.slice(0, Math.max(2, limit)); + return uniqueStrings(selected.map((item) => `${item.label}: ${item.helpText}`), limit); +} +function buildNaturalClarificationHints(input) { + const hints = []; + if (input.missingAnchors.period) { + hints.push("Укажи период проверки (например, июль 2020)."); + } + if (input.missingAnchors.account) { + hints.push("Укажи счет или связку счетов (например, 60/62, 19/68 или 20/44)."); + } + if (input.missingAnchors.counterparty) { + hints.push("Добавь контрагента или договор, чтобы зафиксировать контур проверки."); + } + if (input.missingAnchors.documentOrObject) { + hints.push("Укажи документ или объект, от которого строить проверку цепочки."); + } + if (input.missingAnchors.anomalyType) { + hints.push("Уточни тип отклонения: разрыв цепочки, неверное закрытие или аномальный хвост."); + } + if (input.coverageReport.clarification_needed_for.length > 0) { + hints.push(`Закрой уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`); + } + return uniqueStrings(hints, 5); +} +function shouldUseBoundaryFallbackReply(input) { + if (input.mode === "out_of_scope") { + return true; + } + if (input.mode !== "clarification_required" && input.mode !== "no_grounded") { + return false; + } + const hasNoEvidenceRoutes = input.okResultsCount === 0 && input.partialResultsCount === 0; + const hasNoConfirmedCoverage = input.coverageReport.requirements_covered === 0 && + input.coverageReport.requirements_partially_covered.length === 0; + const groundingBlocked = input.groundingCheck.status === "no_grounded_answer" || + input.groundingCheck.status === "partial" || + input.groundingCheck.status === "route_mismatch_blocked"; + return hasNoEvidenceRoutes && hasNoConfirmedCoverage && groundingBlocked; +} +function buildBoundaryFallbackReply(input) { + const nearbyCapabilities = pickBoundaryCapabilityLines(input.userMessage, 3); + if (input.focusDomain === null) { + return sanitizeUserFacingReply([ + "По этому запросу у меня нет надежного доменного покрытия, поэтому даю мягкий отказ вместо технического шаблона.", + nearbyCapabilities.length > 0 ? `Что могу сделать рядом по смыслу:\n${formatList(nearbyCapabilities)}` : "", + "Переформулируй вопрос через один из вариантов выше, и я сразу перейду к проверке по данным 1С." + ] + .filter(Boolean) + .join("\n\n")); + } + const clarificationHints = buildNaturalClarificationHints({ + missingAnchors: input.missingAnchors, + coverageReport: input.coverageReport + }); + return sanitizeUserFacingReply([ + `Сейчас не могу надежно ответить по сценарию ${formatNarrativeDomainLabel(input.focusDomain)}: не хватает опоры.`, + clarificationHints.length > 0 ? `Чтобы сразу перейти к проверке, уточни:\n${formatList(clarificationHints)}` : "", + nearbyCapabilities.length > 0 ? `Если удобнее, могу начать с близкого сценария:\n${formatList(nearbyCapabilities.slice(0, 2))}` : "" + ] + .filter(Boolean) + .join("\n\n")); +} function ensureSentence(value) { const sanitized = sanitizeUserText(value) ?? String(value ?? "").trim(); const normalized = sanitized.replace(/\s+/g, " ").trim(); @@ -3548,6 +3654,13 @@ function composeAssistantAnswerV11(input) { normalizationPeriodExplicit: Boolean(input.normalizationPeriodExplicit), companyAnchors: input.companyAnchors ?? null }); + const useBoundaryFallbackReply = shouldUseBoundaryFallbackReply({ + mode: guardedDecision.mode, + groundingCheck: input.groundingCheck, + coverageReport: input.coverageReport, + okResultsCount: okResults.length, + partialResultsCount: partialResults.length + }); const hasProblemWeakSignal = policySignals.narrowing_strength !== "strong" || policySignals.minimum_evidence_failed || limitationReasonCodes.includes("missing_mechanism") || @@ -3563,6 +3676,7 @@ function composeAssistantAnswerV11(input) { guardedDecision.mode === "clarification_required" || (guardedDecision.mode === "focused_grounded" && hasProblemWeakSignal); const shouldUseProblemCentricAnswer = Boolean(input.enableProblemCentricAnswerV1) && + !useBoundaryFallbackReply && !hardBlockedMode && problemCentricModeEligible && (!focusedStrong || hasProblemWeakSignal) && @@ -3689,13 +3803,21 @@ function composeAssistantAnswerV11(input) { clarification_questions: clarificationQuestions } }; - return { - assistant_reply: renderPolicyReply(answerStructure, { + const finalAssistantReply = useBoundaryFallbackReply + ? buildBoundaryFallbackReply({ + userMessage: input.userMessage, + focusDomain: focusNarrativeDomain, + missingAnchors, + coverageReport: input.coverageReport + }) + : renderPolicyReply(answerStructure, { questionType, focusDomain: focusNarrativeDomain, anchors: anchorUsage, userMessage: input.userMessage - }), + }); + return { + assistant_reply: finalAssistantReply, fallback_type: guardedDecision.fallback_type, reply_type: guardedDecision.reply_type, answer_structure_v11: answerStructure, diff --git a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js index 0468bec..82fc8c2 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js @@ -33,6 +33,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) { effectiveAddressUserMessage: addressInputMessage, followupContext, llmPreDecomposeMeta: addressPreDecompose, + sessionItems: input.sessionItems, useMock: input.useMock }); const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose); diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js index cb88959..2b830e4 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeGuards.js @@ -1,4 +1,7 @@ "use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveTemporalGuard = resolveTemporalGuard; exports.applyTemporalHintToExecutionPlan = applyTemporalHintToExecutionPlan; @@ -8,6 +11,7 @@ exports.applyDomainPolarityGuardToRetrievalResults = applyDomainPolarityGuardToR exports.applyEvidenceAdmissibilityGate = applyEvidenceAdmissibilityGate; exports.evaluateGroundedAnswerEligibility = evaluateGroundedAnswerEligibility; exports.applyEligibilityToGroundingCheck = applyEligibilityToGroundingCheck; +const iconv_lite_1 = __importDefault(require("iconv-lite")); const JULY_YEAR = "2020"; const JULY_MONTH = "07"; const JULY_WINDOW = { @@ -747,8 +751,65 @@ function applyTemporalHintToExecutionPlan(executionPlan, temporal) { }; }); } +function mojibakeScoreForRuntimeGuards(value) { + const source = String(value ?? ""); + const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; + const latin = (source.match(/[A-Za-z]/g) ?? []).length; + const hardMarkers = (source.match(/[ѓ“‚„…†‡€‰‹‰ЉЊ‹Џ‘’“”•–—™љ›њћџ]/g) ?? []).length; + const pairMarkers = (source.match(/(?:Р.|С.|Гђ.|Г‘.)/g) ?? []).length; + const doubleEncodedMarkers = (source.match(/(?:Р“.|Р’.|Гѓ.|Г‚.)/gu) ?? []).length; + return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2; +} +function looksLikeMojibakeForRuntimeGuards(value) { + const source = String(value ?? ""); + if (!source.trim()) { + return false; + } + if (/[ѓ“‚„…†‡€‰‹‰ЉЊ‹Џ‘’“”•–—™љ›њћџ]/.test(source)) { + return true; + } + if ((source.match(/(?:Р.|С.|Гђ.|Г‘.)/g) ?? []).length >= 2) { + return true; + } + return (source.match(/(?:Р“.|Р’.|Гѓ.|Г‚.)/gu) ?? []).length >= 2; +} +function repairRuntimeGuardsMojibake(value) { + const source = String(value ?? ""); + if (!looksLikeMojibakeForRuntimeGuards(source)) { + return source; + } + let candidate = source; + for (let pass = 0; pass < 3; pass += 1) { + let improved = false; + try { + const fromWin1251 = iconv_lite_1.default.encode(candidate, "win1251").toString("utf8"); + if (mojibakeScoreForRuntimeGuards(fromWin1251) > mojibakeScoreForRuntimeGuards(candidate)) { + candidate = fromWin1251; + improved = true; + } + } + catch (_error) { + // noop + } + try { + const fromLatin1 = Buffer.from(candidate, "latin1").toString("utf8"); + if (mojibakeScoreForRuntimeGuards(fromLatin1) > mojibakeScoreForRuntimeGuards(candidate)) { + candidate = fromLatin1; + improved = true; + } + } + catch (_error) { + // noop + } + if (!improved) { + break; + } + } + return candidate; +} function resolveDomainPolarityGuard(input) { - const lower = String(input.userMessage ?? "").toLowerCase(); + const repairedMessage = repairRuntimeGuardsMojibake(String(input.userMessage ?? "")); + const lower = repairedMessage.toLowerCase(); const accountExtraction = extractAccountsFromTextDetailed(lower); const accounts = uniqueStrings([...(input.companyAnchors?.accounts ?? []), ...accountExtraction.resolved_account_anchors]); const prefixes = new Set(accounts.map((item) => accountPrefix(item)).filter((item) => Boolean(item))); @@ -1397,7 +1458,7 @@ function applyEligibilityToGroundingCheck(groundingCheck, eligibility) { const reasonMap = { admissible_evidence_count_zero: "Недостаточно подтвержденных данных для уверенного ответа.", critical_domain_or_account_contradiction: "Есть противоречие по выбранному домену или контуру счета.", - temporal_guard_failed_out_of_snapshot_window: "Запрошенный период выходит за доступный срез данных.", + temporal_guard_failed_out_of_snapshot_window: "Запрошенный период выходит за доступный срез данных. Temporal anchor outside snapshot window.", temporal_guard_ambiguous_limited: "Период в вопросе определен недостаточно точно.", business_scope_generic_unresolved: "Не удалось надежно привязать вопрос к конкретному бизнес-контексту.", polarity_guard_limited_unresolved_polarity: "Не удалось однозначно определить сторону расчета (нам должны или мы должны).", diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index ac754ba..c5740f7 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -1086,7 +1086,12 @@ function hasCrossScopeConflictWithState(userMessage, state) { const inferredDomain = inferP0DomainFromMessage(userMessage); const stateDomain = compactWhitespace(state.followup_context?.active_domain ?? state.focus.domain ?? ""); if (inferredDomain && stateDomain && inferredDomain !== stateDomain) { - return true; + const followupDomainRefinement = hasFollowupMarker(userMessage) || + hasReferentialPointer(userMessage) || + hasPeriodLiteral(userMessage); + if (!followupDomainRefinement) { + return true; + } } const explicitAccounts = extractAccountTokens(userMessage); const fallbackAccounts = explicitAccounts.length > 0 ? explicitAccounts : extractFollowupAccountAnchorsLoose(userMessage); @@ -1116,9 +1121,11 @@ function inferP0DomainFromMessage(text) { return null; } function hasStrongFollowupAnchors(userMessage, state) { + const normalizedMessage = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const periodRefinementCue = /(?:^(?:\u0430\s+)?\u0435\u0441\u043b\u0438|\u0442\u043e\u043b\u044c\u043a\u043e\s+\u0437\u0430|\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c|\u043f\u043e\s+\u043f\u0435\u0440\u0438\u043e\u0434\u0443|\u0437\u0430\s+\u0438\u044e\u043d\u044c|\u0437\u0430\s+\u0438\u044e\u043b\u044c)/iu.test(normalizedMessage); const explicitPeriod = extractNormalizedPeriodLiteral(userMessage); if (explicitPeriod && state.focus.period && explicitPeriod !== state.focus.period) { - const periodLooksLikeFollowupRefinement = hasFollowupMarker(userMessage) || hasReferentialPointer(userMessage); + const periodLooksLikeFollowupRefinement = hasFollowupMarker(userMessage) || hasReferentialPointer(userMessage) || periodRefinementCue; if (!periodLooksLikeFollowupRefinement) { return true; } @@ -3013,26 +3020,33 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll reason: dataScopeMetaQuery ? "assistant_data_scope_query_detected" : "assistant_capability_query_detected" }; } + const directDeepAnalysisSignal = hasDirectDeepAnalysisSignal(rawMessageForGate) || + hasDirectDeepAnalysisSignal(repairedInputMessage); + const deepAnalysisPreferenceSignal = directDeepAnalysisSignal || + hasDeepAnalysisPreferenceSignal(rawMessageForGate) || + hasDeepAnalysisPreferenceSignal(repairedInputMessage); const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(repairedInputMessage || addressInputMessage); const hasClassifierSignal = modeDetection.mode === "address_query"; const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence); const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); - const llmContractIntentConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent_confidence); + const llmCanonicalEntitySignal = /(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase())); + const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis"; const hasLlmCanonicalSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) && - llmContractMode === "address_query" && - llmContractModeConfidence !== "low" && - llmContractIntent !== null && - llmContractIntent !== "unknown" && - llmContractIntentConfidence !== "low"; + ((llmContractMode === "address_query" && llmContractModeConfidence !== "low") || + (llmCanonicalAppliedSignal && + (hasStrongDataIntentSignal(repairedInputMessage) || llmCanonicalEntitySignal))); const hasLlmCanonicalDataSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) && Boolean(llmPreDecomposeMeta?.applied) && - llmContractMode === "address_query" && + (llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) && hasStrongDataIntentSignal(repairedInputMessage); + const sameDateAccountFollowupSignal = hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate) || + hasSameDateAccountFollowupSignalForPredecompose(repairedInputMessage); const hasLexicalAddressSignal = isAddressLlmPreDecomposeCandidate(addressInputMessage) || isAddressLlmPreDecomposeCandidate(repairedInputMessage) || hasAccountingSignal(addressInputMessage) || - hasAccountingSignal(repairedInputMessage); + hasAccountingSignal(repairedInputMessage) || + sameDateAccountFollowupSignal; const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && llmContractIntent === "unknown"; @@ -3080,6 +3094,125 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll reason: "no_address_signal_after_l0" }; } +function hasLooseAllTimeAddressLookupSignal(text) { + const repaired = repairAddressMojibake(String(text ?? "")); + const normalized = compactWhitespace(repaired.toLowerCase()); + if (!normalized) { + return false; + } + if (shouldHandleAsAssistantCapabilityMetaQuery(normalized) || hasAssistantDataScopeMetaQuestionSignal(normalized)) { + return false; + } + const hasAllTimeSignal = /(?:\u0437\u0430\s+\u0432\u0435\u0441\u044c\s+\u043f\u0435\u0440\u0438\u043e\u0434|\u0437\u0430\s+\u0432\u0441\u0435\s+\u0432\u0440\u0435\u043c\u044f|\u0437\u0430\s+\u0432\u0441\u044e\s+\u0438\u0441\u0442\u043e\u0440\u0438(?:\u044e|\u0438)|for\s+all\s+time|all\s+time|entire\s+period|full\s+period)/iu.test(normalized); + if (!hasAllTimeSignal) { + return false; + } + return /(?:\u0447\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u0447[\u0435\u0451]\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u043a\u0430\u0436\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|show|list|find)/iu.test(normalized); +} +function hasDeepSessionContinuationSignal(input) { + const sessionItems = Array.isArray(input?.sessionItems) ? input.sessionItems : []; + if (sessionItems.length === 0) { + return false; + } + const previousDebug = findLastAssistantLivingChatDebug(sessionItems); + if (!previousDebug || typeof previousDebug !== "object") { + return false; + } + const investigationState = previousDebug.investigation_state_snapshot; + if (!investigationState || typeof investigationState !== "object") { + return false; + } + const candidateTexts = [ + input?.rawUserMessage, + input?.repairedRawUserMessage, + input?.effectiveAddressUserMessage, + input?.repairedEffectiveAddressUserMessage + ] + .map((value) => compactWhitespace(repairAddressMojibake(String(value ?? "")).toLowerCase())) + .filter((value) => value.length > 0); + if (candidateTexts.length === 0) { + return false; + } + return candidateTexts.some((text) => { + const hasContinuationCue = /^(?:\u0438|\u0430|\u0442\u0430\u043a\u0436\u0435|\u0435\u0449[\u0435\u0451]|\u0434\u043e\u0431\u0430\u0432\u044c|\u0434\u043e\u043f\u043e\u043b\u043d\u0438|\u0443\u0442\u043e\u0447\u043d\u0438|\u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438|\u0442\u0435\u043f\u0435\u0440\u044c|then|also|and)\b/iu.test(text) || + /(?:\u043f\u043e\s+\u0442\u043e\u043c\u0443\s+\u0436\u0435|\u043f\u043e\s+\u044d\u0442\u043e\u043c\u0443|\u0432\s+\u044d\u0442\u043e\u043c\s+\u0436\u0435|\u0438\s+\u043f\u043e\s+\u043f\u0435\u0440\u0438\u043e\u0434\u0443|\u0434\u043e\u0431\u0430\u0432\u044c\s+\u0443\u0442\u043e\u0447\u043d\u0435\u043d\u0438\u0435|\u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u043c|\u0430\s+\u0435\u0441\u043b\u0438|\u0435\u0441\u043b\u0438\s+\u0442\u043e\u043b\u044c\u043a\u043e|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e)/iu.test(text); + const hasAccountOrPeriodCue = /(?:\u0441\u0447[\u0435\u0451]\u0442|account|\b\d{2}(?:[.,]\d{1,2})?\b|\b20\d{2}(?:[-/.]\d{1,2})?\b|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(text); + const hasDeepRebindCue = /(?:\u0430\u043c\u043e\u0440\u0442\u0438\u0437|fixed\s*asset|\u043e\u0441\b|\u043d\u0434\u0441|vat|\u0440\u0430\u0437\u0440\u044b\u0432|\u0446\u0435\u043f\u043e\u0447\u043a|\u0430\u043d\u043e\u043c\u0430\u043b|lifecycle|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447)/iu.test(text); + if (hasContinuationCue && (hasAccountOrPeriodCue || hasDeepRebindCue)) { + return true; + } + return hasDeepRebindCue && hasAccountOrPeriodCue; + }); +} +function hasDeepAnalysisPreferenceSignal(text) { + const repaired = repairAddressMojibake(String(text ?? "")); + const lower = compactWhitespace(repaired.toLowerCase()); + if (!lower) { + return false; + } + const riskOrAnomalySignal = /(?:\u0440\u0438\u0441\u043a|risk|\u0430\u043d\u043e\u043c\u0430\u043b|anomal|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442|conflict|deviation|\u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d|\u043d\u0435\u0441\u044b\u043a\u043e\u0432\u043a|\u043d\u0435\u0441\u0445\u043e\u0434|\u043e\u0448\u0438\u0431|error|issue|\u043f\u0440\u043e\u0431\u043b\u0435\u043c)/iu.test(lower); + const chainSignal = /(?:\u0446\u0435\u043f\u043e\u0447\u043a|chain|trace\s*chain|lifecycle|\u0436\u0438\u0437\u043d\u0435\u043d\u043d[\u0430-\u044f]+\s+\u0446\u0438\u043a\u043b|state\s+transition|\u0440\u0430\u0437\u0440\u044b\u0432[\u0430-\u044f]*)/iu.test(lower); + const diagnosticsSignal = /(?:\u0440\u0430\u0437\u043b\u043e\u0436\u0438|\u0434\u0435\u043a\u043e\u043c\u043f\u043e\u0437|\u0440\u0430\u0437\u0431\u0435\u0440\u0438|\u043f\u043e\u0447\u0435\u043c\u0443|why|\u043a\u043e\u0440\u043d\u0435\u0432[\u0430-\u044f]+\s+\u043f\u0440\u0438\u0447\u0438\u043d|root\s*cause|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c[\u0430-\u044f]*|\u0433\u0434\u0435\s+\u0440\u0430\u0437\u0440\u044b\u0432|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower); + const closureSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|\u043d\u0435\s+\u0437\u0430\u043a\u0440\u044b\u043b[\u0430-\u044f]*|\u0445\u0432\u043e\u0441\u0442[\u0430-\u044f]*)/iu.test(lower); + const closureIntentSignal = /(?:\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|period\s*close|close\s+period)/iu.test(lower); + const closureDiagnosticPhraseSignal = /(?:\u0447\u0442\u043e(?:\s+\S+){0,8}\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442)/iu.test(lower); + const signalVsNoiseDiagnostic = /(?:\u043d\u0435\s+\u043f\u0440\u043e\u0441\u0442\u043e\s+(?:\u043d\u0430\s+)?\u0448\u0443\u043c|\u043f\u043e\u0445\u043e\u0436[\u0438\u0435]\s+(?:\u0438\u043c\u0435\u043d\u043d\u043e\s+)?\u043d\u0430\s+\u043f\u0440\u043e\u0431\u043b\u0435\u043c)/iu.test(lower); + const lifecycleMismatchSignal = /(?:\u043d\u0435\s+\u0442\u0435\u043c\s+\u0442\u0438\u043f(?:\u043e\u043c)?\s+\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043d\u0435\s+\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434|wrong\s+closing\s+document|expected\s+transition)/iu.test(lower); + const lifecycleTransitionGapSignal = /(?:\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u043f\u0435\u0440\u0435\u0445\u043e\u0434[\u0430-\u044f]*\s+\u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432|\u0441\u0442\u0430\u0434\u0438[\u0438\u044f\u0435]\s+.*\u043f\u0440\u043e\u0439\u0434\u0435\u043d.*\u043f\u0435\u0440\u0435\u0445\u043e\u0434)/iu.test(lower); + const expectedActualMismatchSignal = /(?:\u0444\u0430\u043a\u0442\u0438\u0447\u0435\u0441\u043a[\u0430-\u044f]+\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d[\u0438\u0435\u044f]+\s+.*\u0440\u0430\u0441\u0445\u043e\u0434[\u0430-\u044f]*\s+\u0441\s+\u043e\u0436\u0438\u0434\u0430\u0435\u043c|\u043e\u0436\u0438\u0434\u0430\u0435\u043c[\u0430-\u044f]+\s+\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d[\u0430-\u044f]*\s+\u0441\u043f\u0438\u0441\u0430\u043d)/iu.test(lower); + return riskOrAnomalySignal || + lifecycleMismatchSignal || + (chainSignal && lifecycleTransitionGapSignal) || + expectedActualMismatchSignal || + (chainSignal && diagnosticsSignal) || + (riskOrAnomalySignal && (chainSignal || closureSignal || diagnosticsSignal || closureIntentSignal)) || + (diagnosticsSignal && closureIntentSignal) || + closureDiagnosticPhraseSignal || + signalVsNoiseDiagnostic; +} +function hasDirectDeepAnalysisSignal(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized); +} +function hasStrictDeepInvestigationCue(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + const hasInvestigativeVerb = /(?:\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c)|\u0440\u0430\u0437\u0431\u0435\u0440(?:\u0438|\u0430\u0442\u044c)|\u0440\u0430\u0437\u043b\u043e\u0436(?:\u0438|\u0438\u0442\u044c)|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|root\s*cause|trace\s*chain)/iu.test(normalized); + if (!hasInvestigativeVerb) { + return false; + } + return /(?:\u0445\u0432\u043e\u0441\u0442|\u0440\u0430\u0437\u0440\u044b\u0432|\u0446\u0435\u043f\u043e\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]|\u043e\u0431\u044a\u0435\u043a\u0442(?:\u0443)?\s+\u0440\u0430\u0441\u0447(?:\u0435|\u0451)\u0442|lifecycle|state\s+transition)/iu.test(normalized); +} +function hasAggregateBusinessAnalyticsSignal(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + const hasMetricCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u0440\u0435\u043d\u0442\u0430\u0431\u0435\u043b|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b|turnover|revenue|profit|margin)/iu.test(normalized); + if (!hasMetricCue) { + return false; + } + const hasRankingOrTrendCue = /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)|\u0442\u043e\u043f|\u043b\u0443\u0447\u0448|\u0445\u0443\u0434\u0448|\u043c\u0430\u043a\u0441(?:\u0438\u043c\u0443\u043c)?|\u043c\u0438\u043d(?:\u0438\u043c\u0443\u043c)?|\u0434\u0438\u043d\u0430\u043c|\u0442\u0440\u0435\u043d\u0434|\u0441\u0440\u0430\u0432\u043d|ranking|top|best|worst)/iu.test(normalized); + const hasPeriodAggregateCue = /(?:\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u0433\u043e\u0434(?:\u0430|\u0443|\u044b)?|year|years|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446|\u043f\u0435\u0440\u0438\u043e\u0434)/iu.test(normalized); + return hasRankingOrTrendCue || hasPeriodAggregateCue; +} +const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ + "list_open_contracts", + "open_items_by_counterparty_or_contract", + "list_documents_by_contract", + "bank_operations_by_contract", + "list_documents_by_counterparty", + "bank_operations_by_counterparty", + "list_contracts_by_counterparty", + "contract_usage_overview", + "contract_usage_and_value", + "vat_payable_forecast" +]); function resolveAssistantOrchestrationDecision(input) { const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage); @@ -3088,6 +3221,7 @@ function resolveAssistantOrchestrationDecision(input) { const followupContext = input?.followupContext ?? null; const llmPreDecomposeMeta = input?.llmPreDecomposeMeta ?? null; const useMock = Boolean(input?.useMock); + const sessionItems = Array.isArray(input?.sessionItems) ? input.sessionItems : null; const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawUserMessage) || hasAssistantDataScopeMetaQuestionSignal(repairedRawUserMessage) || hasAssistantDataScopeMetaQuestionSignal(effectiveAddressUserMessage) || @@ -3100,9 +3234,21 @@ function resolveAssistantOrchestrationDecision(input) { hasDataRetrievalRequestSignal(repairedRawUserMessage) || hasDataRetrievalRequestSignal(effectiveAddressUserMessage) || hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage); + const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) || + hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) || + hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) || + hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage); const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage; const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample); const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample); + const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const strictDeepInvestigationCueDetected = hasStrictDeepInvestigationCue(rawUserMessage) || + hasStrictDeepInvestigationCue(repairedRawUserMessage) || + hasStrictDeepInvestigationCue(effectiveAddressUserMessage) || + hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage); + const keepAddressLaneByIntent = Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))) && + !strictDeepInvestigationCueDetected; const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) || hasStrongDataIntentSignal(repairedRawUserMessage) || hasStrongDataIntentSignal(effectiveAddressUserMessage) || @@ -3175,11 +3321,58 @@ function resolveAssistantOrchestrationDecision(input) { }; } const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); + const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); + const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && + llmPreDecomposeMeta?.applied && + llmContractMode === "address_query") || + hasSameDateAccountFollowupSignalForPredecompose(rawUserMessage) || + hasSameDateAccountFollowupSignalForPredecompose(effectiveAddressUserMessage) || + hasSameDateAccountFollowupSignalForPredecompose(repairedRawUserMessage) || + hasSameDateAccountFollowupSignalForPredecompose(repairedEffectiveAddressUserMessage) || + hasLooseAllTimeAddressLookupSignal(rawUserMessage) || + hasLooseAllTimeAddressLookupSignal(effectiveAddressUserMessage) || + hasLooseAllTimeAddressLookupSignal(repairedRawUserMessage) || + hasLooseAllTimeAddressLookupSignal(repairedEffectiveAddressUserMessage) || + hasAddressFollowupContextSignal(rawUserMessage) || + hasAddressFollowupContextSignal(effectiveAddressUserMessage) || + hasAddressFollowupContextSignal(repairedRawUserMessage) || + hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage)); + const unsupportedIntentOrMode = modeDetection.mode !== "address_query" && + (intentResolution.intent === "unknown" || llmContractMode === "unsupported"); const unsupportedAddressIntentFallbackToDeep = Boolean(!followupContext && baseToolGate?.runAddressLane && - modeDetection.mode !== "address_query" && - intentResolution.intent === "unknown" && - strongDataSignal); + unsupportedIntentOrMode && + strongDataSignal && + !preserveAddressLaneSignal); + const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) || + hasDeepAnalysisPreferenceSignal(repairedRawUserMessage) || + hasDeepAnalysisPreferenceSignal(effectiveAddressUserMessage) || + hasDeepAnalysisPreferenceSignal(repairedEffectiveAddressUserMessage) || + hasDirectDeepAnalysisSignal(rawUserMessage) || + hasDirectDeepAnalysisSignal(repairedRawUserMessage) || + hasDirectDeepAnalysisSignal(effectiveAddressUserMessage) || + hasDirectDeepAnalysisSignal(repairedEffectiveAddressUserMessage)); + const vatExplainFollowupSignal = Boolean(followupContext && + toNonEmptyString(followupContext.previous_intent) === "vat_payable_forecast" && + /(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); + const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane && + deepAnalysisPreferenceDetected && + !keepAddressLaneByIntent && + !vatExplainFollowupSignal && + (!followupContext || !dataRetrievalSignal)); + const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane && + aggregateBusinessAnalyticsSignal && + !keepAddressLaneByIntent && + !followupContext); + const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && + baseToolGate?.runAddressLane && + hasDeepSessionContinuationSignal({ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage, + sessionItems + })); let runAddressLane = Boolean(baseToolGate?.runAddressLane); let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane"); let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0"); @@ -3188,6 +3381,23 @@ function resolveAssistantOrchestrationDecision(input) { toolGateDecision = "skip_address_lane"; toolGateReason = "address_signal_unsupported_intent_fallback_to_deep"; } + if (deepAnalysisSignalFallbackToDeep && !unsupportedAddressIntentFallbackToDeep) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "deep_analysis_signal_fallback_to_deep"; + } + if (aggregateAnalyticsFallbackToDeep && + !unsupportedAddressIntentFallbackToDeep && + !deepAnalysisSignalFallbackToDeep) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "aggregate_analytics_signal_fallback_to_deep"; + } + if (deepSessionContinuationFallbackToDeep) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "deep_session_continuation_fallback_to_deep"; + } let livingDecision = resolveLivingAssistantModeDecision({ userMessage: rawUserMessage, addressLaneTriggered: runAddressLane, @@ -3201,6 +3411,26 @@ function resolveAssistantOrchestrationDecision(input) { reason: "unsupported_address_intent_fallback_to_deep" }; } + if (deepAnalysisSignalFallbackToDeep && !unsupportedAddressIntentFallbackToDeep) { + livingDecision = { + mode: "deep_analysis", + reason: "deep_analysis_signal_fallback_to_deep" + }; + } + if (aggregateAnalyticsFallbackToDeep && + !unsupportedAddressIntentFallbackToDeep && + !deepAnalysisSignalFallbackToDeep) { + livingDecision = { + mode: "deep_analysis", + reason: "aggregate_analytics_signal_fallback_to_deep" + }; + } + if (deepSessionContinuationFallbackToDeep) { + livingDecision = { + mode: "deep_analysis", + reason: "deep_session_continuation_fallback_to_deep" + }; + } return { runAddressLane, toolGateDecision, @@ -3218,6 +3448,9 @@ function resolveAssistantOrchestrationDecision(input) { data_retrieval_signal_detected: dataRetrievalSignal, followup_context_detected: Boolean(followupContext), unsupported_address_intent_fallback_to_deep: unsupportedAddressIntentFallbackToDeep, + deep_analysis_signal_fallback_to_deep: deepAnalysisSignalFallbackToDeep, + aggregate_analytics_signal_fallback_to_deep: aggregateAnalyticsFallbackToDeep, + deep_session_continuation_fallback_to_deep: deepSessionContinuationFallbackToDeep, final_decision: { run_address_lane: runAddressLane, tool_gate_decision: toolGateDecision, @@ -3237,6 +3470,11 @@ function hasDataRetrievalRequestSignal(text) { if (!lower) { return false; } + const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower); + const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower); + if (hasRussianRetrievalAction && hasRussianRetrievalObject) { + return true; + } const hasExplicitRetrievalAction = /(?:\bпокажи\b|\bпоказать\b|\bвыведи\b|\bнайди\b|\bсписок\b|\bдай\b|\bраскрой\b|\bshow\b|\blist\b|\bfind\b|\bcount\b)/i.test(lower); const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bwhich\b|\bwho\b)/i.test(lower); if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { @@ -3403,6 +3641,10 @@ function hasAssistantDataScopeMetaQuestionSignal(text) { if (!normalized) { return false; } + const hasSlangScopeQuestion = /(?:\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440(?:\u0430\u043c|\u044b|\u0430)?|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u0444\u0438\u0440\u043c(?:\u0430\u043c|\u0435|\u0443|\u0430)).*(?:\u043c\u043e\u0436(?:\u0435\u043c|\u043d\u043e)|\u0440\u0430\u0431\u043e\u0442|\u043e\u0431\u0449\u0430\u0442|\u043f\u043e\u0434\u0440\u0443\u0431|\u043f\u043e\u0434\u043a\u043b\u044e\u0447)|(?:\u0431\u0430\u0437\u0430\s+\u043a\u0430\u043a\u043e\u0439\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0444\u0438\u0440\u043c))|(?:\u043a\u0430\u043a\u0430\u044f\s+\u0431\u0430\u0437\u0430\s+(?:\u043f\u043e\u0434\u043a\u043b\u044e\u0447|\u0430\u043a\u0442\u0438\u0432)))/iu.test(normalized); + if (hasSlangScopeQuestion) { + return true; + } const hasBaseOrTenantObject = /(?:баз(?:а|е|у|ы)?|тенант|tenant|контур)/i.test(normalized); const hasCompanyObject = /(?:компан(?:ия|ии|ию|ией)|компин(?:ия|ии|ию|ией)?|компини(?:я|и|ю|ей)?|компани[яеию]|организац(?:ия|ии|ию|ией)|контор(?:а|ы|у|ой)?|фирм(?:а|ы|у|ой)?)/i.test(normalized); const hasConnectionCue = /(?:подключен(?:а|о|ы)?|подруб|воткнут|активн(?:ый|ая)\s+канал|mcp-?канал|канал)/i.test(normalized); diff --git a/llm_normalizer/backend/dist/utils/log.js b/llm_normalizer/backend/dist/utils/log.js index e1df011..cacea79 100644 --- a/llm_normalizer/backend/dist/utils/log.js +++ b/llm_normalizer/backend/dist/utils/log.js @@ -22,6 +22,9 @@ function redactObject(value) { return value; } function logJson(entry) { + if (process.env.NODE_ENV === "test" && process.env.FEATURE_JSON_STDOUT_LOGS_IN_TESTS !== "1") { + return; + } const safe = { ...entry, details: redactObject(entry.details) diff --git a/llm_normalizer/backend/src/routes/autoRuns.ts b/llm_normalizer/backend/src/routes/autoRuns.ts index 6cb79ff..e2e9d9e 100644 --- a/llm_normalizer/backend/src/routes/autoRuns.ts +++ b/llm_normalizer/backend/src/routes/autoRuns.ts @@ -1415,6 +1415,67 @@ function sanitizeGeneratedQuestion(value: string): string { .trim(); } +const AUTOGEN_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu; +const AUTOGEN_QUESTION_TAIL_PATTERNS: RegExp[] = [ + /^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu +]; + +function stripAutogenQuestionSuffix(value: string): string { + return sanitizeGeneratedQuestion(value).replace(/[?!.:,;]+$/u, "").trim(); +} + +function isAutogenQuestionPlaceholder(value: string): boolean { + const core = stripAutogenQuestionSuffix(value).toLowerCase(); + return core.length > 0 && AUTOGEN_QUESTION_PLACEHOLDER_PATTERN.test(core); +} + +function isLikelyAutogenQuestionTail(value: string): boolean { + const core = stripAutogenQuestionSuffix(value).toLowerCase(); + if (!core) { + return false; + } + if (isAutogenQuestionPlaceholder(core)) { + return true; + } + return AUTOGEN_QUESTION_TAIL_PATTERNS.some((pattern) => pattern.test(core)); +} + +function mergeAutogenQuestionTail(baseQuestion: string, tail: string): string { + const base = stripAutogenQuestionSuffix(baseQuestion); + const suffix = stripAutogenQuestionSuffix(tail); + if (!base) { + return suffix ? `${suffix}?` : ""; + } + if (!suffix) { + return `${base}?`; + } + return `${base} ${suffix}?` + .replace(/\s+/g, " ") + .trim(); +} + +function normalizeAutogenQuestionCandidates(candidates: string[]): string[] { + const normalized: string[] = []; + for (const candidate of candidates) { + const question = sanitizeGeneratedQuestion(candidate); + if (!question) { + continue; + } + if (isAutogenQuestionPlaceholder(question)) { + continue; + } + if (isLikelyAutogenQuestionTail(question) && normalized.length > 0) { + const merged = mergeAutogenQuestionTail(normalized[normalized.length - 1], question); + if (merged) { + normalized[normalized.length - 1] = merged; + } + continue; + } + normalized.push(question); + } + return normalized.filter((item) => item.length > 0); +} + function splitQuestionCandidates(rawText: string): string[] { const normalized = repairAutogenMojibake(rawText).replace(/\r/g, "\n").trim(); if (!normalized) return []; @@ -1426,18 +1487,26 @@ function splitQuestionCandidates(rawText: string): string[] { .map((line) => sanitizeGeneratedQuestion(line)) .filter((line) => line.length > 0); if (byLines.length > 1) { - return byLines; + return normalizeAutogenQuestionCandidates(byLines); } const questionMarkCount = (unescaped.match(/\?/g) ?? []).length; if (questionMarkCount > 1) { - const byQuestion = unescaped - .split("?") - .map((chunk) => sanitizeGeneratedQuestion(chunk)) - .filter((chunk) => chunk.length > 0) - .map((chunk) => (chunk.endsWith("?") ? chunk : `${chunk}?`)); - if (byQuestion.length > 1) { - return byQuestion; + const questionChunks = Array.from(unescaped.matchAll(/[^?]+(?:\?|$)/g)) + .map((match) => sanitizeGeneratedQuestion(match[0])) + .filter((chunk) => chunk.length > 0); + if (questionChunks.length > 1) { + const canSafelySplit = questionChunks.every( + (chunk) => + !isAutogenQuestionPlaceholder(chunk) && + !isLikelyAutogenQuestionTail(chunk) && + sanitizeGeneratedQuestion(chunk).length >= 18 + ); + if (canSafelySplit) { + return normalizeAutogenQuestionCandidates( + questionChunks.map((chunk) => (chunk.endsWith("?") ? chunk : `${chunk}?`)) + ); + } } } @@ -1445,11 +1514,11 @@ function splitQuestionCandidates(rawText: string): string[] { .map((match) => sanitizeGeneratedQuestion(match[1])) .filter((line) => line.length > 0); if (quoted.length > 1) { - return quoted; + return normalizeAutogenQuestionCandidates(quoted); } const cleaned = sanitizeGeneratedQuestion(unescaped); - return cleaned ? [cleaned] : []; + return cleaned ? normalizeAutogenQuestionCandidates([cleaned]) : []; } function parseAutogenOutputJson(rawText: string): unknown | null { @@ -1496,7 +1565,8 @@ function collectQuestionsFromCandidate(value: unknown, depth = 0): string[] { } if (Array.isArray(value)) { - return value.flatMap((item) => collectQuestionsFromCandidate(item, depth + 1)); + const expanded = value.flatMap((item) => collectQuestionsFromCandidate(item, depth + 1)); + return normalizeAutogenQuestionCandidates(expanded); } if (typeof value === "string") { @@ -1549,6 +1619,11 @@ function extractQuestionsFromAutogenOutput(rawText: string): string[] { return collectQuestionsFromCandidate(rawText); } +export const __autoRunsQuestionTestUtils = { + splitQuestionCandidates, + extractQuestionsFromAutogenOutput +}; + async function generateQwenSeedQuestionsLive(input: { count: number; domain: string | null; diff --git a/llm_normalizer/backend/src/routes/eval.ts b/llm_normalizer/backend/src/routes/eval.ts index 9ec0728..147dac5 100644 --- a/llm_normalizer/backend/src/routes/eval.ts +++ b/llm_normalizer/backend/src/routes/eval.ts @@ -73,6 +73,67 @@ function normalizeQuestionChunk(value: string): string { .trim(); } +const RUNTIME_QUESTION_PLACEHOLDER_PATTERN = /^(?:questions?|вопросы?|список\s+вопросов)$/iu; +const RUNTIME_QUESTION_TAIL_PATTERNS: RegExp[] = [ + /^(?:без\s+воды|по\s+факту|и\s+коротко|коротко|прям(?:\s+)?сейчас|за\s+весь\s+период|по\s+делу)\??$/iu +]; + +function stripQuestionSuffix(value: string): string { + return normalizeQuestionChunk(value).replace(/[?!.:,;]+$/u, "").trim(); +} + +function isRuntimeQuestionPlaceholder(value: string): boolean { + const core = stripQuestionSuffix(value).toLowerCase(); + return core.length > 0 && RUNTIME_QUESTION_PLACEHOLDER_PATTERN.test(core); +} + +function isLikelyRuntimeQuestionTail(value: string): boolean { + const core = stripQuestionSuffix(value).toLowerCase(); + if (!core) { + return false; + } + if (isRuntimeQuestionPlaceholder(core)) { + return true; + } + return RUNTIME_QUESTION_TAIL_PATTERNS.some((pattern) => pattern.test(core)); +} + +function mergeRuntimeQuestionTail(baseQuestion: string, tail: string): string { + const base = stripQuestionSuffix(baseQuestion); + const suffix = stripQuestionSuffix(tail); + if (!base) { + return suffix ? `${suffix}?` : ""; + } + if (!suffix) { + return `${base}?`; + } + return `${base} ${suffix}?` + .replace(/\s+/g, " ") + .trim(); +} + +function normalizeRuntimeQuestionList(items: string[]): string[] { + const normalized: string[] = []; + for (const item of items) { + const chunk = normalizeQuestionChunk(item); + if (!chunk) { + continue; + } + if (isRuntimeQuestionPlaceholder(chunk)) { + continue; + } + if (isLikelyRuntimeQuestionTail(chunk) && normalized.length > 0) { + const merged = mergeRuntimeQuestionTail(normalized[normalized.length - 1], chunk); + if (merged) { + normalized[normalized.length - 1] = merged; + } + continue; + } + normalized.push(chunk); + } + return normalized.filter((item) => item.length > 0); +} + function splitQuestionCandidate(raw: string): string[] { const normalized = String(raw ?? "").replace(/\r/g, "\n").trim(); if (!normalized) { @@ -87,18 +148,32 @@ function splitQuestionCandidate(raw: string): string[] { const chunks: string[] = []; for (const line of source) { + const normalizedLine = normalizeQuestionChunk(line); + if (!normalizedLine || isRuntimeQuestionPlaceholder(normalizedLine)) { + continue; + } const questionLike = Array.from(line.matchAll(/[^?]+(?:\?|$)/g)) .map((match) => normalizeQuestionChunk(match[0])) .filter((item) => item.length > 0); if (questionLike.length > 1) { - for (const item of questionLike) { - chunks.push(item.endsWith("?") ? item : `${item}?`); + const canSafelySplit = questionLike.every( + (item) => + !isRuntimeQuestionPlaceholder(item) && + !isLikelyRuntimeQuestionTail(item) && + normalizeQuestionChunk(item).length >= 18 + ); + if (canSafelySplit) { + for (const item of questionLike) { + chunks.push(item.endsWith("?") ? item : `${item}?`); + } + } else { + chunks.push(normalizedLine); } continue; } - chunks.push(normalizeQuestionChunk(line)); + chunks.push(normalizedLine); } - return chunks.filter((item) => item.length > 0); + return normalizeRuntimeQuestionList(chunks); } function normalizeRuntimeQuestions(value: unknown): string[] { @@ -109,7 +184,7 @@ function normalizeRuntimeQuestions(value: unknown): string[] { return []; } - const expanded = raw.flatMap((item) => splitQuestionCandidate(item)); + const expanded = normalizeRuntimeQuestionList(raw.flatMap((item) => splitQuestionCandidate(item))); const deduped: string[] = []; const seen = new Set(); for (const item of expanded) { @@ -122,6 +197,11 @@ function normalizeRuntimeQuestions(value: unknown): string[] { return deduped; } +export const __evalRouteTestUtils = { + splitQuestionCandidate, + normalizeRuntimeQuestions +}; + function normalizeCaseIds(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; diff --git a/llm_normalizer/backend/src/services/answerComposer.ts b/llm_normalizer/backend/src/services/answerComposer.ts index 49d095f..2313adc 100644 --- a/llm_normalizer/backend/src/services/answerComposer.ts +++ b/llm_normalizer/backend/src/services/answerComposer.ts @@ -1854,6 +1854,146 @@ function buildAnswerSummary(mode: PolicyMode): string { type P0NarrativeDomain = "settlements_60_62" | "vat_document_register_book" | "month_close_costs_20_44" | null; +interface BoundaryCapabilitySuggestion { + key: "settlements_60_62" | "vat_document_register_book" | "month_close_costs_20_44"; + label: string; + helpText: string; + signals: RegExp; +} + +const BOUNDARY_CAPABILITY_SUGGESTIONS: BoundaryCapabilitySuggestion[] = [ + { + key: "settlements_60_62", + label: "Взаиморасчеты 60/62", + helpText: "найти хвосты, незакрытые оплаты и рисковые связки по контрагентам.", + signals: /(контраг|долг|сальдо|взаиморасчет|оплат|аванс|покупат|поставщ|банк|выписк|\b60\b|\b62\b|\b76\b)/iu + }, + { + key: "vat_document_register_book", + label: "НДС 19/68", + helpText: "проверить цепочку документ -> счет-фактура -> регистр -> книга.", + signals: /(ндс|сч[её]т[-\s]?фактур|регистр|книга\s+покуп|книга\s+продаж|декларац|\b19\b|\b68\b)/iu + }, + { + key: "month_close_costs_20_44", + label: "Закрытие месяца 20/44", + helpText: "проверить распределение затрат и остатки после регламентных операций.", + signals: /(закрыти[ея]|месяц|затрат|распределени|рбп|аморт|основн|ос\b|\b20\b|\b25\b|\b26\b|\b44\b)/iu + } +]; + +function formatNarrativeDomainLabel(domain: P0NarrativeDomain): string { + if (domain === "settlements_60_62") { + return "взаиморасчетов 60/62"; + } + if (domain === "vat_document_register_book") { + return "НДС-контура 19/68"; + } + if (domain === "month_close_costs_20_44") { + return "закрытия месяца (20/44)"; + } + return "доступного учетного контура"; +} + +function pickBoundaryCapabilityLines(userMessage: string, limit = 3): string[] { + const text = String(userMessage ?? "").toLowerCase(); + const scored = BOUNDARY_CAPABILITY_SUGGESTIONS.map((item, index) => ({ + item, + score: (text.match(item.signals) ?? []).length, + order: index + })); + const ranked = scored + .slice() + .sort((left, right) => right.score - left.score || left.order - right.order) + .map((entry) => entry.item); + const selected = ranked.slice(0, Math.max(2, limit)); + return uniqueStrings(selected.map((item) => `${item.label}: ${item.helpText}`), limit); +} + +function buildNaturalClarificationHints(input: { + missingAnchors: MissingAnchors; + coverageReport: RequirementCoverageReport; +}): string[] { + const hints: string[] = []; + if (input.missingAnchors.period) { + hints.push("Укажи период проверки (например, июль 2020)."); + } + if (input.missingAnchors.account) { + hints.push("Укажи счет или связку счетов (например, 60/62, 19/68 или 20/44)."); + } + if (input.missingAnchors.counterparty) { + hints.push("Добавь контрагента или договор, чтобы зафиксировать контур проверки."); + } + if (input.missingAnchors.documentOrObject) { + hints.push("Укажи документ или объект, от которого строить проверку цепочки."); + } + if (input.missingAnchors.anomalyType) { + hints.push("Уточни тип отклонения: разрыв цепочки, неверное закрытие или аномальный хвост."); + } + if (input.coverageReport.clarification_needed_for.length > 0) { + hints.push(`Закрой уточнения для требований: ${input.coverageReport.clarification_needed_for.join(", ")}.`); + } + return uniqueStrings(hints, 5); +} + +function shouldUseBoundaryFallbackReply(input: { + mode: PolicyMode; + groundingCheck: AnswerGroundingCheck; + coverageReport: RequirementCoverageReport; + okResultsCount: number; + partialResultsCount: number; +}): boolean { + if (input.mode === "out_of_scope") { + return true; + } + if (input.mode !== "clarification_required" && input.mode !== "no_grounded") { + return false; + } + const hasNoEvidenceRoutes = input.okResultsCount === 0 && input.partialResultsCount === 0; + const hasNoConfirmedCoverage = + input.coverageReport.requirements_covered === 0 && + input.coverageReport.requirements_partially_covered.length === 0; + const groundingBlocked = + input.groundingCheck.status === "no_grounded_answer" || + input.groundingCheck.status === "partial" || + input.groundingCheck.status === "route_mismatch_blocked"; + return hasNoEvidenceRoutes && hasNoConfirmedCoverage && groundingBlocked; +} + +function buildBoundaryFallbackReply(input: { + userMessage: string; + focusDomain: P0NarrativeDomain; + missingAnchors: MissingAnchors; + coverageReport: RequirementCoverageReport; +}): string { + const nearbyCapabilities = pickBoundaryCapabilityLines(input.userMessage, 3); + if (input.focusDomain === null) { + return sanitizeUserFacingReply( + [ + "По этому запросу у меня нет надежного доменного покрытия, поэтому даю мягкий отказ вместо технического шаблона.", + nearbyCapabilities.length > 0 ? `Что могу сделать рядом по смыслу:\n${formatList(nearbyCapabilities)}` : "", + "Переформулируй вопрос через один из вариантов выше, и я сразу перейду к проверке по данным 1С." + ] + .filter(Boolean) + .join("\n\n") + ); + } + + const clarificationHints = buildNaturalClarificationHints({ + missingAnchors: input.missingAnchors, + coverageReport: input.coverageReport + }); + return sanitizeUserFacingReply( + [ + `Сейчас не могу надежно ответить по сценарию ${formatNarrativeDomainLabel(input.focusDomain)}: не хватает опоры.`, + clarificationHints.length > 0 ? `Чтобы сразу перейти к проверке, уточни:\n${formatList(clarificationHints)}` : "", + nearbyCapabilities.length > 0 ? `Если удобнее, могу начать с близкого сценария:\n${formatList(nearbyCapabilities.slice(0, 2))}` : "" + ] + .filter(Boolean) + .join("\n\n") + ); +} + function ensureSentence(value: string): string { const sanitized = sanitizeUserText(value) ?? String(value ?? "").trim(); const normalized = sanitized.replace(/\s+/g, " ").trim(); @@ -4219,6 +4359,13 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp normalizationPeriodExplicit: Boolean(input.normalizationPeriodExplicit), companyAnchors: input.companyAnchors ?? null }); + const useBoundaryFallbackReply = shouldUseBoundaryFallbackReply({ + mode: guardedDecision.mode, + groundingCheck: input.groundingCheck, + coverageReport: input.coverageReport, + okResultsCount: okResults.length, + partialResultsCount: partialResults.length + }); const hasProblemWeakSignal = policySignals.narrowing_strength !== "strong" || policySignals.minimum_evidence_failed || @@ -4238,6 +4385,7 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp (guardedDecision.mode === "focused_grounded" && hasProblemWeakSignal); const shouldUseProblemCentricAnswer = Boolean(input.enableProblemCentricAnswerV1) && + !useBoundaryFallbackReply && !hardBlockedMode && problemCentricModeEligible && (!focusedStrong || hasProblemWeakSignal) && @@ -4382,13 +4530,22 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp } }; + const finalAssistantReply = useBoundaryFallbackReply + ? buildBoundaryFallbackReply({ + userMessage: input.userMessage, + focusDomain: focusNarrativeDomain, + missingAnchors, + coverageReport: input.coverageReport + }) + : renderPolicyReply(answerStructure, { + questionType, + focusDomain: focusNarrativeDomain, + anchors: anchorUsage, + userMessage: input.userMessage + }); + return { - assistant_reply: renderPolicyReply(answerStructure, { - questionType, - focusDomain: focusNarrativeDomain, - anchors: anchorUsage, - userMessage: input.userMessage - }), + assistant_reply: finalAssistantReply, fallback_type: guardedDecision.fallback_type, reply_type: guardedDecision.reply_type, answer_structure_v11: answerStructure, diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 0c64288..61720c5 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2986,12 +2986,12 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const llmContractModeConfidence = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode_confidence); const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); - const llmCanonicalEntitySignal = /(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|customer|supplier|counterparty|company|vendor|client|\b[a-z]{2,}\b)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase())); + const llmCanonicalEntitySignal = /(?:\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043e\u043c\u043f\u0430\u043d|customer|supplier|counterparty|company|vendor|client)/iu.test(compactWhitespace(repairedInputMessage.toLowerCase())); const llmCanonicalAppliedSignal = Boolean(llmPreDecomposeMeta?.applied) && llmContractMode !== "deep_analysis"; const hasLlmCanonicalSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) && ((llmContractMode === "address_query" && llmContractModeConfidence !== "low") || (llmCanonicalAppliedSignal && - (hasStrongDataIntentSignal(repairedInputMessage) || llmCanonicalEntitySignal || llmContractMode === "unsupported"))); + (hasStrongDataIntentSignal(repairedInputMessage) || llmCanonicalEntitySignal))); const hasLlmCanonicalDataSignal = Boolean(llmPreDecomposeMeta?.llmCanonicalCandidateDetected) && Boolean(llmPreDecomposeMeta?.applied) && (llmContractMode === "address_query" || llmContractMode === "unsupported" || llmContractMode === null) && @@ -3133,6 +3133,42 @@ function hasDirectDeepAnalysisSignal(text) { } return /(?:\u0440\u0430\u0437\u043b\u043e\u0436|\u0446\u0435\u043f\u043e\u0447|lifecycle|\u0440\u0430\u0437\u0440\u044b\u0432|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|\u0437\u0430\u043a\u0440\u044b\u0442[\u0430-\u044f]*|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized); } +function hasStrictDeepInvestigationCue(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + const hasInvestigativeVerb = /(?:\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c)|\u0440\u0430\u0437\u0431\u0435\u0440(?:\u0438|\u0430\u0442\u044c)|\u0440\u0430\u0437\u043b\u043e\u0436(?:\u0438|\u0438\u0442\u044c)|\u043f\u043e\u0447\u0435\u043c\u0443|\u043c\u0435\u0445\u0430\u043d\u0438\u0437\u043c|root\s*cause|trace\s*chain)/iu.test(normalized); + if (!hasInvestigativeVerb) { + return false; + } + return /(?:\u0445\u0432\u043e\u0441\u0442|\u0440\u0430\u0437\u0440\u044b\u0432|\u0446\u0435\u043f\u043e\u0447|\u0430\u043d\u043e\u043c\u0430\u043b|\u043f\u0440\u043e\u0442\u0438\u0432\u043e\u0440\u0435\u0447|\u0437\u0430\u043a\u0440\u044b\u0442\u0438[\u0435\u044f]|\u043e\u0431\u044a\u0435\u043a\u0442(?:\u0443)?\s+\u0440\u0430\u0441\u0447(?:\u0435|\u0451)\u0442|lifecycle|state\s+transition)/iu.test(normalized); +} +function hasAggregateBusinessAnalyticsSignal(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + const hasMetricCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u0440\u0435\u043d\u0442\u0430\u0431\u0435\u043b|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u0435\u043b|turnover|revenue|profit|margin)/iu.test(normalized); + if (!hasMetricCue) { + return false; + } + const hasRankingOrTrendCue = /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435)|\u0442\u043e\u043f|\u043b\u0443\u0447\u0448|\u0445\u0443\u0434\u0448|\u043c\u0430\u043a\u0441(?:\u0438\u043c\u0443\u043c)?|\u043c\u0438\u043d(?:\u0438\u043c\u0443\u043c)?|\u0434\u0438\u043d\u0430\u043c|\u0442\u0440\u0435\u043d\u0434|\u0441\u0440\u0430\u0432\u043d|ranking|top|best|worst)/iu.test(normalized); + const hasPeriodAggregateCue = /(?:\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\d{4}\s+\u0433\u043e\u0434|\u0433\u043e\u0434(?:\u0430|\u0443|\u044b)?|year|years|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446|\u043f\u0435\u0440\u0438\u043e\u0434)/iu.test(normalized); + return hasRankingOrTrendCue || hasPeriodAggregateCue; +} +const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ + "list_open_contracts", + "open_items_by_counterparty_or_contract", + "list_documents_by_contract", + "bank_operations_by_contract", + "list_documents_by_counterparty", + "bank_operations_by_counterparty", + "list_contracts_by_counterparty", + "contract_usage_overview", + "contract_usage_and_value", + "vat_payable_forecast" +]); export function resolveAssistantOrchestrationDecision(input) { const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage); @@ -3154,9 +3190,21 @@ export function resolveAssistantOrchestrationDecision(input) { hasDataRetrievalRequestSignal(repairedRawUserMessage) || hasDataRetrievalRequestSignal(effectiveAddressUserMessage) || hasDataRetrievalRequestSignal(repairedEffectiveAddressUserMessage); + const aggregateBusinessAnalyticsSignal = hasAggregateBusinessAnalyticsSignal(rawUserMessage) || + hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) || + hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) || + hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage); const modeSample = repairedEffectiveAddressUserMessage || effectiveAddressUserMessage; const modeDetection = (0, addressQueryClassifier_1.detectAddressQuestionMode)(modeSample); const intentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(modeSample); + const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const strictDeepInvestigationCueDetected = hasStrictDeepInvestigationCue(rawUserMessage) || + hasStrictDeepInvestigationCue(repairedRawUserMessage) || + hasStrictDeepInvestigationCue(effectiveAddressUserMessage) || + hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage); + const keepAddressLaneByIntent = Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))) && + !strictDeepInvestigationCueDetected; const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) || hasStrongDataIntentSignal(repairedRawUserMessage) || hasStrongDataIntentSignal(effectiveAddressUserMessage) || @@ -3232,7 +3280,7 @@ export function resolveAssistantOrchestrationDecision(input) { const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && - (llmContractMode === "address_query" || llmContractMode === "unsupported")) || + llmContractMode === "address_query") || hasSameDateAccountFollowupSignalForPredecompose(rawUserMessage) || hasSameDateAccountFollowupSignalForPredecompose(effectiveAddressUserMessage) || hasSameDateAccountFollowupSignalForPredecompose(repairedRawUserMessage) || @@ -3245,10 +3293,11 @@ export function resolveAssistantOrchestrationDecision(input) { hasAddressFollowupContextSignal(effectiveAddressUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) || hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage)); + const unsupportedIntentOrMode = modeDetection.mode !== "address_query" && + (intentResolution.intent === "unknown" || llmContractMode === "unsupported"); const unsupportedAddressIntentFallbackToDeep = Boolean(!followupContext && baseToolGate?.runAddressLane && - modeDetection.mode !== "address_query" && - intentResolution.intent === "unknown" && + unsupportedIntentOrMode && strongDataSignal && !preserveAddressLaneSignal); const deepAnalysisPreferenceDetected = Boolean(hasDeepAnalysisPreferenceSignal(rawUserMessage) || @@ -3264,8 +3313,13 @@ export function resolveAssistantOrchestrationDecision(input) { /(?:\u043f\u043e\u0447\u0435\u043c\u0443|why).*(?:\u043f\u0440\u043e\u0433\u043d\u043e\u0437|forecast).*(?:\u0443\u043f\u043b\u0430\u0442|payable|\b0\b)/iu.test(compactWhitespace(`${repairedRawUserMessage} ${repairedEffectiveAddressUserMessage}`))); const deepAnalysisSignalFallbackToDeep = Boolean(baseToolGate?.runAddressLane && deepAnalysisPreferenceDetected && + !keepAddressLaneByIntent && !vatExplainFollowupSignal && (!followupContext || !dataRetrievalSignal)); + const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane && + aggregateBusinessAnalyticsSignal && + !keepAddressLaneByIntent && + !followupContext); const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && baseToolGate?.runAddressLane && hasDeepSessionContinuationSignal({ @@ -3288,6 +3342,13 @@ export function resolveAssistantOrchestrationDecision(input) { toolGateDecision = "skip_address_lane"; toolGateReason = "deep_analysis_signal_fallback_to_deep"; } + if (aggregateAnalyticsFallbackToDeep && + !unsupportedAddressIntentFallbackToDeep && + !deepAnalysisSignalFallbackToDeep) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "aggregate_analytics_signal_fallback_to_deep"; + } if (deepSessionContinuationFallbackToDeep) { runAddressLane = false; toolGateDecision = "skip_address_lane"; @@ -3312,6 +3373,14 @@ export function resolveAssistantOrchestrationDecision(input) { reason: "deep_analysis_signal_fallback_to_deep" }; } + if (aggregateAnalyticsFallbackToDeep && + !unsupportedAddressIntentFallbackToDeep && + !deepAnalysisSignalFallbackToDeep) { + livingDecision = { + mode: "deep_analysis", + reason: "aggregate_analytics_signal_fallback_to_deep" + }; + } if (deepSessionContinuationFallbackToDeep) { livingDecision = { mode: "deep_analysis", @@ -3336,6 +3405,7 @@ export function resolveAssistantOrchestrationDecision(input) { followup_context_detected: Boolean(followupContext), unsupported_address_intent_fallback_to_deep: unsupportedAddressIntentFallbackToDeep, deep_analysis_signal_fallback_to_deep: deepAnalysisSignalFallbackToDeep, + aggregate_analytics_signal_fallback_to_deep: aggregateAnalyticsFallbackToDeep, deep_session_continuation_fallback_to_deep: deepSessionContinuationFallbackToDeep, final_decision: { run_address_lane: runAddressLane, @@ -3356,6 +3426,11 @@ function hasDataRetrievalRequestSignal(text) { if (!lower) { return false; } + const hasRussianRetrievalAction = /(?:^|\s)(?:\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|\u043d\u0430\u0439\u0434\u0438|\u0432\u044b\u0432\u0435\u0434\u0438|\u0434\u0430\u0439|\u0440\u0430\u0441\u043a\u0440\u043e\u0439|\u0441\u043f\u0438\u0441\u043e\u043a)(?:$|[\s,.!?;:])/iu.test(lower); + const hasRussianRetrievalObject = /(?:\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0441\u0442\u0430\u0442|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043e\u043f\u0435\u0440\u0430\u0446|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043a\u043b\u0438\u0435\u043d\u0442|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446)/iu.test(lower); + if (hasRussianRetrievalAction && hasRussianRetrievalObject) { + return true; + } const hasExplicitRetrievalAction = /(?:\bпокажи\b|\bпоказать\b|\bвыведи\b|\bнайди\b|\bсписок\b|\bдай\b|\bраскрой\b|\bshow\b|\blist\b|\bfind\b|\bcount\b)/i.test(lower); const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bwhich\b|\bwho\b)/i.test(lower); if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { @@ -3522,6 +3597,10 @@ function hasAssistantDataScopeMetaQuestionSignal(text) { if (!normalized) { return false; } + const hasSlangScopeQuestion = /(?:\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440(?:\u0430\u043c|\u044b|\u0430)?|\u043a\u043e\u043c\u043f\u0430\u043d(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446(?:\u0438\u044f\u043c|\u0438\u0438|\u0438\u044e|\u0438\u044f)|\u0444\u0438\u0440\u043c(?:\u0430\u043c|\u0435|\u0443|\u0430)).*(?:\u043c\u043e\u0436(?:\u0435\u043c|\u043d\u043e)|\u0440\u0430\u0431\u043e\u0442|\u043e\u0431\u0449\u0430\u0442|\u043f\u043e\u0434\u0440\u0443\u0431|\u043f\u043e\u0434\u043a\u043b\u044e\u0447)|(?:\u0431\u0430\u0437\u0430\s+\u043a\u0430\u043a\u043e\u0439\s+(?:\u043a\u043e\u043d\u0442\u043e\u0440|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0444\u0438\u0440\u043c))|(?:\u043a\u0430\u043a\u0430\u044f\s+\u0431\u0430\u0437\u0430\s+(?:\u043f\u043e\u0434\u043a\u043b\u044e\u0447|\u0430\u043a\u0442\u0438\u0432)))/iu.test(normalized); + if (hasSlangScopeQuestion) { + return true; + } const hasBaseOrTenantObject = /(?:баз(?:а|е|у|ы)?|тенант|tenant|контур)/i.test(normalized); const hasCompanyObject = /(?:компан(?:ия|ии|ию|ией)|компин(?:ия|ии|ию|ией)?|компини(?:я|и|ю|ей)?|компани[яеию]|организац(?:ия|ии|ию|ией)|контор(?:а|ы|у|ой)?|фирм(?:а|ы|у|ой)?)/i.test(normalized); const hasConnectionCue = /(?:подключен(?:а|о|ы)?|подруб|воткнут|активн(?:ый|ая)\s+канал|mcp-?канал|канал)/i.test(normalized); diff --git a/llm_normalizer/backend/tests/assistantBoundaryFallbackReply.test.ts b/llm_normalizer/backend/tests/assistantBoundaryFallbackReply.test.ts new file mode 100644 index 0000000..f1ae446 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantBoundaryFallbackReply.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { composeAssistantAnswer } from "../src/services/answerComposer"; + +function buildRouteSummary(fallbackType: "none" | "clarification" | "out_of_scope" = "none") { + return { + mode: "deterministic_v2" as const, + message_in_scope: true, + scope_confidence: "high" as const, + planner: { + total_fragments: 1, + in_scope_fragments: 1, + out_of_scope_fragments: 0, + discarded_fragments: 0, + contains_multiple_tasks: false + }, + decisions: [], + fallback: { + type: fallbackType, + message: null + } + }; +} + +function buildCoverageReport() { + return { + requirements_total: 0, + requirements_covered: 0, + requirements_uncovered: [], + requirements_partially_covered: [], + clarification_needed_for: [], + out_of_scope_requirements: [] + }; +} + +describe("assistant boundary fallback reply", () => { + it("uses soft refusal without template sections when domain is not covered", () => { + const output = composeAssistantAnswer({ + userMessage: "Скажи курс доллара на завтра и дай прогноз инфляции.", + routeSummary: buildRouteSummary("none"), + retrievalResults: [], + requirements: [], + coverageReport: buildCoverageReport(), + groundingCheck: { + status: "no_grounded_answer", + route_subject_match: false, + missing_requirements: [], + reasons: ["no grounded support"], + why_included_summary: [], + selection_reason_summary: [] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("clarification_required"); + expect(output.assistant_reply).toMatch(/мягкий отказ/i); + expect(output.assistant_reply).toContain("Что могу сделать рядом по смыслу:"); + expect(output.assistant_reply).not.toContain("Что сломано:"); + }); + + it("for covered domain without anchors uses soft clarification with nearby capability", () => { + const output = composeAssistantAnswer({ + userMessage: "Покажи где по контрагентам хвосты по оплатам.", + routeSummary: buildRouteSummary("none"), + retrievalResults: [], + requirements: [], + coverageReport: buildCoverageReport(), + groundingCheck: { + status: "no_grounded_answer", + route_subject_match: false, + missing_requirements: [], + reasons: ["no grounded support"], + why_included_summary: [], + selection_reason_summary: [] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("clarification_required"); + expect(output.assistant_reply).toMatch(/не могу надежно ответить по сценарию/i); + expect(output.assistant_reply).toContain("Чтобы сразу перейти к проверке, уточни:"); + expect(output.assistant_reply).toContain("Если удобнее, могу начать с близкого сценария:"); + expect(output.assistant_reply).not.toContain("Что сломано:"); + }); + + it("keeps out_of_scope reply type but responds with soft fallback text", () => { + const output = composeAssistantAnswer({ + userMessage: "Скажи прогноз погоды на выходные.", + routeSummary: buildRouteSummary("out_of_scope"), + retrievalResults: [], + requirements: [], + coverageReport: buildCoverageReport(), + groundingCheck: { + status: "no_grounded_answer", + route_subject_match: false, + missing_requirements: [], + reasons: ["out of scope"], + why_included_summary: [], + selection_reason_summary: [] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("out_of_scope"); + expect(output.assistant_reply).toMatch(/мягкий отказ|не могу надежно/i); + expect(output.assistant_reply).not.toContain("Что сломано:"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index adc2f82..3e0c7ee 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -126,6 +126,18 @@ describe("assistant living router mode decision", () => { expect(decision.reason).toBe("assistant_data_scope_query_detected"); }); + it("routes slang data-scope wording 'по каким конторам можем общаться' to chat", () => { + const decision = resolveLivingAssistantModeDecision({ + userMessage: "\u043f\u043e \u043a\u0430\u043a\u0438\u043c \u043a\u043e\u043d\u0442\u043e\u0440\u0430\u043c \u043c\u043e\u0436\u0435\u043c \u043e\u0431\u0449\u0430\u0442\u044c\u0441\u044f?", + addressLaneTriggered: false, + useMock: false, + predecomposeMode: "unsupported", + predecomposeModeConfidence: "low" + }); + expect(decision.mode).toBe("chat"); + expect(decision.reason).toBe("assistant_data_scope_query_detected"); + }); + it("routes data-scope wording without question mark when interrogative token is present", () => { const decision = resolveLivingAssistantModeDecision({ userMessage: "каза какой компании подключена к 1с", @@ -185,6 +197,54 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("address_lane_triggered"); }); + it("routes unsupported turnover-by-organization query to deep analysis", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434", + effectiveAddressUserMessage: "\u041e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0441\u0447\u0435\u0442\u0443 '\u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430' \u0437\u0430 2020 \u0433\u043e\u0434.", + followupContext: null, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "account_balance_snapshot", + intent_confidence: "high" + } + } as any, + useMock: false + }); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.livingMode).toBe("deep_analysis"); + expect([ + "address_signal_unsupported_intent_fallback_to_deep", + "aggregate_analytics_signal_fallback_to_deep" + ]).toContain(String(decision.toolGateReason)); + expect([ + "unsupported_address_intent_fallback_to_deep", + "aggregate_analytics_signal_fallback_to_deep" + ]).toContain(String(decision.livingReason)); + }); + + it("routes profitability ranking query to deep analysis instead of address lane", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?", + effectiveAddressUserMessage: "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?", + followupContext: null, + llmPreDecomposeMeta: null as any, + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("aggregate_analytics_signal_fallback_to_deep"); + expect(decision.livingMode).toBe("deep_analysis"); + expect(decision.livingReason).toBe("aggregate_analytics_signal_fallback_to_deep"); + expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true); + }); + it("keeps VAT explain follow-up in address lane when followup context is present", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "почему прогноз к уплате 0?", @@ -236,6 +296,33 @@ describe("assistant orchestration contract", () => { expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false); }); + it("keeps list_open_contracts query in address lane despite 'unclosed' wording", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31", + effectiveAddressUserMessage: "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043f\u043e \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e \u043d\u0430 \u043a\u043e\u043d\u0435\u0446 \u0434\u0435\u043a\u0430\u0431\u0440\u044f 2020 \u0433\u043e\u0434\u0430.", + followupContext: { + previous_intent: "month_close_costs_20_44" + }, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "address_query", + mode_confidence: "high", + intent: "list_open_contracts", + intent_confidence: "medium" + } + } as any, + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + expect(decision.toolGateReason).toBe("address_mode_classifier_detected"); + }); + it("does not force address lane for deep-analysis unknown intent query with date-like token", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "найди какие либо ошибки на 21 мая 2022 года", diff --git a/llm_normalizer/backend/tests/autoRunsQuestionSplit.test.ts b/llm_normalizer/backend/tests/autoRunsQuestionSplit.test.ts new file mode 100644 index 0000000..bb8ccc3 --- /dev/null +++ b/llm_normalizer/backend/tests/autoRunsQuestionSplit.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { __autoRunsQuestionTestUtils } from "../src/routes/autoRuns"; + +describe("autoruns question extraction", () => { + it("merges conversational tails instead of producing micro-prompts", () => { + const parsed = __autoRunsQuestionTestUtils.splitQuestionCandidates( + "Покажи рисковые хвосты по расчетам с поставщиками? и коротко? без воды?" + ); + + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatch(/коротко/i); + expect(parsed[0]).toMatch(/без воды/i); + }); + + it("keeps independent questions separated", () => { + const parsed = __autoRunsQuestionTestUtils.splitQuestionCandidates( + "Где зависли оплаты по счету 60? Какие акты сверки с риском расхождения по 62?" + ); + + expect(parsed).toHaveLength(2); + }); + + it("extracts questions from JSON payload and skips placeholders", () => { + const parsed = __autoRunsQuestionTestUtils.extractQuestionsFromAutogenOutput( + JSON.stringify({ + questions: ["Вопросы", "Покажи хвосты по поставщикам", "и коротко?"] + }) + ); + + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatch(/поставщик/i); + expect(parsed[0]).toMatch(/коротко/i); + }); +}); diff --git a/llm_normalizer/backend/tests/evalRuntimeQuestionSplit.test.ts b/llm_normalizer/backend/tests/evalRuntimeQuestionSplit.test.ts new file mode 100644 index 0000000..0474b00 --- /dev/null +++ b/llm_normalizer/backend/tests/evalRuntimeQuestionSplit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { __evalRouteTestUtils } from "../src/routes/eval"; + +describe("eval runtime question splitting", () => { + it("merges short conversational tails into previous question", () => { + const parsed = __evalRouteTestUtils.splitQuestionCandidate( + "Покажи контрагентов с риском несверки по акту? и коротко? без воды?" + ); + + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatch(/коротко/i); + expect(parsed[0]).toMatch(/без воды/i); + }); + + it("keeps independent full questions as separate items", () => { + const parsed = __evalRouteTestUtils.splitQuestionCandidate( + "Где зависли оплаты по счету 60? Какие документы не закрылись по 62 за июль 2020?" + ); + + expect(parsed).toHaveLength(2); + expect(parsed[0]).toMatch(/\?/); + expect(parsed[1]).toMatch(/\?/); + }); + + it("normalizes list input and removes placeholders and duplicates", () => { + const parsed = __evalRouteTestUtils.normalizeRuntimeQuestions([ + "Вопросы", + "Покажи хвосты по поставщикам", + "и коротко?" + ]); + + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatch(/поставщик/i); + expect(parsed[0]).toMatch(/коротко/i); + }); +}); diff --git a/llm_normalizer/data/autorun_annotations/annotations.json b/llm_normalizer/data/autorun_annotations/annotations.json index ea7726a..61c22c8 100644 --- a/llm_normalizer/data/autorun_annotations/annotations.json +++ b/llm_normalizer/data/autorun_annotations/annotations.json @@ -673,5 +673,221 @@ "question_text": "Проверь зависшие авансы и уточни, можно ли их перепривязать на текущие отгрузки или пора списывать как нереальные?", "answer_text": "Я ассистент по анализу данных 1С в режиме чтения.\nЧто умею по группам:\n1. НДС: Расчеты и аналитика по НДС на основании данных 1С. (например: vat_period_snapshot, vat_payable_forecast, vat_turnover_breakdown).\n2. Контрагенты: Срезы активности, платежей и документов по контрагентам. (например: list_documents_by_counterparty, bank_operations_by_counterparty, list_contracts_by_counterparty).\n3. Задолженности и расчеты: Аналитика закрытия расчетов, сальдо и признаков незакрытых цепочек. (например: settlement_closure_state, advance_offset_state, open_items_snapshot).\n4. Деньги и остатки: Остатки и динамика по денежным счетам и кассе. (например: balance_snapshot, turnover_by_period).\n5. Ограничения: Операции, которые ассистент не выполняет в этом рантайме. (например: explain_boundary, suggest_safe_next_step).\nЕсли хотите, раскрою любую группу точечно и дам готовую формулировку запроса.\nЧто не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере." } + }, + { + "annotation_id": "ann-mnubukbt-h5fjkfe", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-002", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-002", + "message_index": 1, + "rating": 3, + "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов", + "manual_case_decision": "candidate_for_implementation", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:44:50.920Z", + "updated_at": "2026-04-11T12:44:50.920Z", + "context": { + "message_id": "msg-tL1QVBeDxY", + "trace_id": "Ylge9xWuRuJLvV", + "reply_type": "clarification_required", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?", + "answer_text": "Сейчас не могу надежно ответить по сценарию взаиморасчетов 60/62: не хватает опоры. Чтобы сразу перейти к проверке, уточни:\n- Укажи период проверки (например, июль 2020).\n- Укажи счет или связку счетов (например, 60/62, 19/68 или 20/44).\n- Добавь контрагента или договор, чтобы зафиксировать контур проверки.\n- Укажи документ или объект, от которого строить проверку цепочки.\n- Уточни тип отклонения: разрыв цепочки, неверное закрытие или аномальный хвост. Если удобнее, могу начать с близкого сценария:\n- Взаиморасчеты 60/62: найти хвосты, незакрытые оплаты и рисковые связки по контрагентам.\n- НДС 19/68: проверить цепочку документ -> счет-фактура -> регистр -> книга." + } + }, + { + "annotation_id": "ann-mnubvu0x-3pv3ehr", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-004", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-004", + "message_index": 1, + "rating": 3, + "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - однозначно к дорабюотке - анализируем заказчиков которые чаще встречаются по годам и выводим топ 10", + "manual_case_decision": "needs_dialog_policy_fix", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:45:50.144Z", + "updated_at": "2026-04-11T12:45:50.144Z", + "context": { + "message_id": "msg-rj83nhGOV7", + "trace_id": "address-H9VJ13GWWC", + "reply_type": "partial_coverage", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?", + "answer_text": "Чтобы ответить надежно, нужен более точный ориентир в запросе.\nКоротко: в запросе не хватает конкретного ориентира (контрагент, договор или период).\nЧто можно сделать дальше: укажите контрагента или номер/название договора." + } + }, + { + "annotation_id": "ann-mnubwxmg-4qqv21q", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-005", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-005", + "message_index": 1, + "rating": 3, + "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - простой вопрос - показываем просто открытые договора без приходов денег", + "manual_case_decision": "candidate_for_implementation", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:46:41.463Z", + "updated_at": "2026-04-11T12:46:41.463Z", + "context": { + "message_id": "msg-2kDN4UKCbY", + "trace_id": "address-L0WwEsakCe", + "reply_type": "partial_coverage", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "В каких случаях мы видим ситуацию, когда документы есть, а денег - нет и пока не предвидится?", + "answer_text": "Чтобы ответить надежно, нужен более точный ориентир в запросе.\nКоротко: в запросе не хватает конкретного ориентира (контрагент, договор или период).\nЧто можно сделать дальше: уточните точное имя контрагента или добавьте ИНН." + } + }, + { + "annotation_id": "ann-mnubxf3s-bkoxdg5", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-006", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-006", + "message_index": 1, + "rating": 3, + "comment": "нужен анализ маршрута для ответа на этот вопрос - расшщирение доменов - надо внедрять", + "manual_case_decision": "candidate_for_implementation", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:47:04.120Z", + "updated_at": "2026-04-11T12:47:04.120Z", + "context": { + "message_id": "msg--ZmllegVvV", + "trace_id": "cG51b5sIOWKwTi", + "reply_type": "clarification_required", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "Какие контрагенты висят с закрытыми отгрузками, но с открытыми документами оплаты, что явно выглядит как кейс для ручной проверки?", + "answer_text": "Сейчас не могу надежно ответить по сценарию взаиморасчетов 60/62: не хватает опоры. Чтобы сразу перейти к проверке, уточни:\n- Укажи период проверки (например, июль 2020).\n- Укажи счет или связку счетов (например, 60/62, 19/68 или 20/44).\n- Уточни тип отклонения: разрыв цепочки, неверное закрытие или аномальный хвост.\n- Закрой уточнения для требований: R1. Если удобнее, могу начать с близкого сценария:\n- Взаиморасчеты 60/62: найти хвосты, незакрытые оплаты и рисковые связки по контрагентам.\n- НДС 19/68: проверить цепочку документ -> счет-фактура -> регистр -> книга." + } + }, + { + "annotation_id": "ann-mnubyvd4-kv1ycys", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-009", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-009", + "message_index": 1, + "rating": 3, + "comment": "покеаываем клиентов с открытими договорами которые висят более месяца бенз денег - выводимм том 10", + "manual_case_decision": "candidate_for_implementation", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:48:11.847Z", + "updated_at": "2026-04-11T12:48:11.847Z", + "context": { + "message_id": "msg-6GpvEMKGcQ", + "trace_id": "address-lGGvDiH21w", + "reply_type": "partial_coverage", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?", + "answer_text": "Сейчас этот тип вопроса вне поддерживаемого контура адресного режима.\nКоротко: этот сценарий пока не поддержан в текущем адресном контуре.\nЧто можно сделать дальше: могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету." + } + }, + { + "annotation_id": "ann-mnubzypb-okrr0gn", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-013", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-013", + "message_index": 1, + "rating": 3, + "comment": "однозначно на расширение доменов", + "manual_case_decision": "candidate_for_implementation", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:49:02.831Z", + "updated_at": "2026-04-11T12:49:02.831Z", + "context": { + "message_id": "msg-bZZQxlpKcO", + "trace_id": "address-JiGLTVe3_0", + "reply_type": "partial_coverage", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "По каким контрагентам мы можем заметить такую картину: оплачено меньше, чем отгружено, и это явно требует вмешательства бухгалтера.", + "answer_text": "Сейчас этот тип вопроса вне поддерживаемого контура адресного режима.\nКоротко: этот сценарий пока не поддержан в текущем адресном контуре.\nЧто можно сделать дальше: могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету." + } + }, + { + "annotation_id": "ann-mnuc1qax-x2caipm", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-014", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-014", + "message_index": 1, + "rating": 3, + "comment": "проблема тыт шум - важно показать договора с незакрытыми доками - топ по времени висения - от самой длинной дистании до кооротной", + "manual_case_decision": "needs_dialog_policy_fix", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:50:25.257Z", + "updated_at": "2026-04-11T12:50:25.257Z", + "context": { + "message_id": "msg-3Leq0R75YH", + "trace_id": "E1O-azugbPvNG9", + "reply_type": "clarification_required", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "Какие незакрытые документы по договорам у нас уже давно пора проверить - это грозит серьезными проблемами?", + "answer_text": "По этому запросу у меня нет надежного доменного покрытия, поэтому даю мягкий отказ вместо технического шаблона. Что могу сделать рядом по смыслу:\n- Взаиморасчеты 60/62: найти хвосты, незакрытые оплаты и рисковые связки по контрагентам.\n- НДС 19/68: проверить цепочку документ -> счет-фактура -> регистр -> книга.\n- Закрытие месяца 20/44: проверить распределение затрат и остатки после регламентных операций. Переформулируй вопрос через один из вариантов выше, и я сразу перейду к проверке по данным 1С." + } + }, + { + "annotation_id": "ann-mnuc2887-4k0f7x0", + "run_id": "assistant-stage1-ywEyJgFkC4", + "case_id": "AUTO-015", + "session_id": "assistant-stage1-ywEyJgFkC4-AUTO-015", + "message_index": 1, + "rating": 3, + "comment": "нужен анализ и отработка домена", + "manual_case_decision": "candidate_for_implementation", + "annotation_author": "manual_reviewer", + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "created_at": "2026-04-11T12:50:48.487Z", + "updated_at": "2026-04-11T12:50:48.487Z", + "context": { + "message_id": "msg-6Fqd6XrBv2", + "trace_id": "address-TnBvKVAVfu", + "reply_type": "partial_coverage", + "eval_target": "assistant_stage1", + "prompt_version": "address_query_runtime_v1", + "domain": null, + "query_class": null, + "question_text": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться.", + "answer_text": "Сейчас этот тип вопроса вне поддерживаемого контура адресного режима.\nКоротко: этот сценарий пока не поддержан в текущем адресном контуре.\nЧто можно сделать дальше: могу проверить близкие сценарии: документы/платежи по контрагенту, договоры или остаток по счету." + } } ] \ No newline at end of file diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index 16c214b..95fdf4c 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -1,4 +1,74 @@ [ + { + "generation_id": "gen-mnubheq4-7h5v00u", + "created_at": "2026-04-11T12:34:37.133Z", + "mode": "qwen_seed", + "count": 15, + "domain": null, + "questions": [ + "Кому из контрагентов мы уже месяц отдаем товары, но на счетах все еще красуется минусовое сальдо - это реально зеленый свет для ручного вмешательства?", + "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?", + "Покажи контрагентов, по которым сальдо у нас выглядит так, будто оно врет - ну точно не совпадает с тем, что они нам прислали. Это уже критично для сверки.", + "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?", + "В каких случаях мы видим ситуацию, когда документы есть, а денег - нет и пока не предвидится?", + "Какие контрагенты висят с закрытыми отгрузками, но с открытыми документами оплаты, что явно выглядит как кейс для ручной проверки?", + "Покажи контрагентов, у которых есть неоплаченные задолженности по договорам на конец месяца - это уже красный свет для бухгалтера.", + "По каким заказчикам мы можем выделить непростую картину: сальдо нулевое, а история платежей явно говорит о том, что все не так просто?", + "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?", + "В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности.", + "Покажи контрагентов, по которым на конец месяца сальдо выглядит так, будто документы собраны криво и их нужно перепроверить.", + "Какие у нас зависшие авансы или предоплаты уже давно пора либо закрыть, либо хотя бы проверить - это уже не просто вопрос времени?", + "По каким контрагентам мы можем заметить такую картину: оплачено меньше, чем отгружено, и это явно требует вмешательства бухгалтера.", + "Какие незакрытые документы по договорам у нас уже давно пора проверить - это грозит серьезными проблемами?", + "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться." + ], + "generated_by": "manual_reviewer", + "saved_case_set_file": "assistant_autogen_qwen_seed_20260411123437_gen-mnubheq4-7h5v00u.json", + "context": { + "llm_provider": "local", + "model": "Qwen2.5 14B Instruct 1M", + "assistant_prompt_version": "address_query_runtime_v1", + "decomposition_prompt_version": "normalizer_v2_0_2", + "prompt_fingerprint": "Ты semantic-normalizer для бухгалтерского ассистента NDC.\nТвоя роль: только нормализация запроса пользователя в строгий JSON-контракт.\n\nЖесткие правила:\n1) Не давай бухгалтерский ответ по сути вопроса.\n2) Возвращай только JSON без markdown и пояснений.\n3) JSON обязан соответствовать переданной schema normalized_query_v1.\n4) Если период не указан, не выдумывай его; отмечай ambiguity.\n5) Для цепочек документов/проводок/оплат поднимай causal и cross-entity признаки.\n6) Для точечного object trace (номер/строка/ref) поднимай needs_exact_object_trace=true.\n7) Используй терминологию NDC.\nYou are semantic-normalizer for accounting assistant NDC.\nReturn strict JSON only, no markdown, no comments.\n\nTarget schema: normalized_query_v2_0_2.\n\nCore behavior (v2.0.2):\n1. Decompose message into semantic fragments.\n2. Classify fragment domain relevance and business scope.\n3. Fill route-critical flags and ", + "autogen_personality_id": "general", + "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов, долги нсд, счета, общий вывод по компании - контрагенты, заказчикам, скока денег кто принес и какие остатки по счетам, поиск документов, сальдо, банковские операции, незакрытые договора, документы по договорам, долги, Активность заказчиков по периодам, Поставщики и выплаты" + } + }, + { + "generation_id": "gen-mnua8bfg-00u7c2z", + "created_at": "2026-04-11T11:59:33.340Z", + "mode": "qwen_seed", + "count": 15, + "domain": null, + "questions": [ + "questions", + "По каким покупателям у нас долгий хвост на конец месяца, и это уже больше похоже на системную проблему с цепочкой взаиморасчетов?", + "йо По каким поставщикам у нас на конец месяца остались хвосты, которые уже не похожи на обычную задержку документов, а выглядят как реальная проблема в цепочке?", + "слушай Где по покупателям у нас висит история \"отгрузили - денег нет - закрытия нет\", и по каким контрагентам это уже требует ручной проверки? без воды", + "подскажи плиз Покажи контрагентов, по которым сальдо у нас, скорее всего, не совпадет с их актом сверки, если его запросить прямо сейчас. по факту", + "короче Где у нас есть оплаты, но не хватает документов, которые должны были закрыть взаиморасчеты? и коротко", + "мож По каким контрагентам, наоборот, документы есть, а нормального закрытия оплатами не видно? прям сейчас", + "а ну-ка Есть ли такие зависшие авансы, которые уже давно надо было либо закрыть, либо хотя бы перепроверить руками? за весь период", + "йо Какие реализации на конец периода выглядят так, будто они зависли и будут портить картину по выручке, если их не проверить заранее?", + "слушай По каким отгрузкам видно, что проблема не просто в том, что клиент не оплатил, а в том, что сама связка документов собрана криво? без воды", + "подскажи плиз Покажи реализации, где хвост выглядит особенно неприятно: сумма не маленькая, возраст хвоста уже заметный, и при этом не видно нормального завершения цепочки. по факту", + "короче Где по 90/62 история похожа на \"вроде все проведено, но если копнуть, закрытие держится на кривой связке\"? и коротко", + "мож Есть ли случаи, где реализация попала в период, а подтверждающие документы или оплата до сих пор живут в какой-то полуразобранной логике? прям сейчас", + "а ну-ка По каким продажам на конец месяца видно, что бухгалтер потом будет долго распутывать, почему все это не сошлось нормально? за весь период", + "йо Какие банковские движения выглядят так, будто выписка есть, а нормального отражения в учете под ней не хватает?" + ], + "generated_by": "manual_reviewer", + "saved_case_set_file": "assistant_autogen_qwen_seed_20260411115933_gen-mnua8bfg-00u7c2z.json", + "context": { + "llm_provider": "local", + "model": "Qwen2.5 14B Instruct 1M", + "assistant_prompt_version": "address_query_runtime_v1", + "decomposition_prompt_version": "normalizer_v2_0_2", + "prompt_fingerprint": "Ты semantic-normalizer для бухгалтерского ассистента NDC.\nТвоя роль: только нормализация запроса пользователя в строгий JSON-контракт.\n\nЖесткие правила:\n1) Не давай бухгалтерский ответ по сути вопроса.\n2) Возвращай только JSON без markdown и пояснений.\n3) JSON обязан соответствовать переданной schema normalized_query_v1.\n4) Если период не указан, не выдумывай его; отмечай ambiguity.\n5) Для цепочек документов/проводок/оплат поднимай causal и cross-entity признаки.\n6) Для точечного object trace (номер/строка/ref) поднимай needs_exact_object_trace=true.\n7) Используй терминологию NDC.\nYou are semantic-normalizer for accounting assistant NDC.\nReturn strict JSON only, no markdown, no comments.\n\nTarget schema: normalized_query_v2_0_2.\n\nCore behavior (v2.0.2):\n1. Decompose message into semantic fragments.\n2. Classify fragment domain relevance and business scope.\n3. Fill route-critical flags and ", + "autogen_personality_id": "general", + "autogen_personality_prompt": "Генерируй реалистичные живые вопросы бухгалтера по 1С. Добавляй разговорные формулировки и опечатки, но сохраняй бизнес-смысл. акцент на контрагентов, долги нсд, счета, общий вывод по компании - контрагенты, заказчикам, скока денег кто принес и какие остатки по счетам, поиск документов, сальдо, банковские операции, незакрытые договора, документы по договорам, долги, Активность заказчиков по периодам, Поставщики и выплаты" + } + }, { "generation_id": "gen-mnte8abx-ax3v3tr", "created_at": "2026-04-10T21:03:44.205Z", diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_qwen_seed_20260411115933_gen-mnua8bfg-00u7c2z.json b/llm_normalizer/data/eval_cases/assistant_autogen_qwen_seed_20260411115933_gen-mnua8bfg-00u7c2z.json new file mode 100644 index 0000000..5423ce7 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_qwen_seed_20260411115933_gen-mnua8bfg-00u7c2z.json @@ -0,0 +1,254 @@ +{ + "suite_id": "assistant_autogen_gen-mnua8bfg-00u7c2z", + "suite_version": "0.1.0", + "schema_version": "assistant_autogen_suite_v0_1", + "generated_at": "2026-04-11T11:59:33.340Z", + "generation_id": "gen-mnua8bfg-00u7c2z", + "mode": "qwen_seed", + "domain": null, + "scenario_count": 15, + "case_ids": [ + "AUTO-001", + "AUTO-002", + "AUTO-003", + "AUTO-004", + "AUTO-005", + "AUTO-006", + "AUTO-007", + "AUTO-008", + "AUTO-009", + "AUTO-010", + "AUTO-011", + "AUTO-012", + "AUTO-013", + "AUTO-014", + "AUTO-015" + ], + "cases": [ + { + "case_id": "AUTO-001", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "questions" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-002", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "По каким покупателям у нас долгий хвост на конец месяца, и это уже больше похоже на системную проблему с цепочкой взаиморасчетов?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-003", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "йо По каким поставщикам у нас на конец месяца остались хвосты, которые уже не похожи на обычную задержку документов, а выглядят как реальная проблема в цепочке?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-004", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "слушай Где по покупателям у нас висит история \"отгрузили - денег нет - закрытия нет\", и по каким контрагентам это уже требует ручной проверки? без воды" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-005", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "подскажи плиз Покажи контрагентов, по которым сальдо у нас, скорее всего, не совпадет с их актом сверки, если его запросить прямо сейчас. по факту" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-006", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "короче Где у нас есть оплаты, но не хватает документов, которые должны были закрыть взаиморасчеты? и коротко" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-007", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "мож По каким контрагентам, наоборот, документы есть, а нормального закрытия оплатами не видно? прям сейчас" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-008", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "а ну-ка Есть ли такие зависшие авансы, которые уже давно надо было либо закрыть, либо хотя бы перепроверить руками? за весь период" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-009", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "йо Какие реализации на конец периода выглядят так, будто они зависли и будут портить картину по выручке, если их не проверить заранее?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-010", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "слушай По каким отгрузкам видно, что проблема не просто в том, что клиент не оплатил, а в том, что сама связка документов собрана криво? без воды" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-011", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "подскажи плиз Покажи реализации, где хвост выглядит особенно неприятно: сумма не маленькая, возраст хвоста уже заметный, и при этом не видно нормального завершения цепочки. по факту" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-012", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "короче Где по 90/62 история похожа на \"вроде все проведено, но если копнуть, закрытие держится на кривой связке\"? и коротко" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-013", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "мож Есть ли случаи, где реализация попала в период, а подтверждающие документы или оплата до сих пор живут в какой-то полуразобранной логике? прям сейчас" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-014", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "а ну-ка По каким продажам на конец месяца видно, что бухгалтер потом будет долго распутывать, почему все это не сошлось нормально? за весь период" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-015", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "йо Какие банковские движения выглядят так, будто выписка есть, а нормального отражения в учете под ней не хватает?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_qwen_seed_20260411123437_gen-mnubheq4-7h5v00u.json b/llm_normalizer/data/eval_cases/assistant_autogen_qwen_seed_20260411123437_gen-mnubheq4-7h5v00u.json new file mode 100644 index 0000000..71ef53e --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_qwen_seed_20260411123437_gen-mnubheq4-7h5v00u.json @@ -0,0 +1,254 @@ +{ + "suite_id": "assistant_autogen_gen-mnubheq4-7h5v00u", + "suite_version": "0.1.0", + "schema_version": "assistant_autogen_suite_v0_1", + "generated_at": "2026-04-11T12:34:37.132Z", + "generation_id": "gen-mnubheq4-7h5v00u", + "mode": "qwen_seed", + "domain": null, + "scenario_count": 15, + "case_ids": [ + "AUTO-001", + "AUTO-002", + "AUTO-003", + "AUTO-004", + "AUTO-005", + "AUTO-006", + "AUTO-007", + "AUTO-008", + "AUTO-009", + "AUTO-010", + "AUTO-011", + "AUTO-012", + "AUTO-013", + "AUTO-014", + "AUTO-015" + ], + "cases": [ + { + "case_id": "AUTO-001", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Кому из контрагентов мы уже месяц отдаем товары, но на счетах все еще красуется минусовое сальдо - это реально зеленый свет для ручного вмешательства?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-002", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-003", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, по которым сальдо у нас выглядит так, будто оно врет - ну точно не совпадает с тем, что они нам прислали. Это уже критично для сверки." + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-004", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-005", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "В каких случаях мы видим ситуацию, когда документы есть, а денег - нет и пока не предвидится?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-006", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие контрагенты висят с закрытыми отгрузками, но с открытыми документами оплаты, что явно выглядит как кейс для ручной проверки?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-007", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, у которых есть неоплаченные задолженности по договорам на конец месяца - это уже красный свет для бухгалтера." + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-008", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "По каким заказчикам мы можем выделить непростую картину: сальдо нулевое, а история платежей явно говорит о том, что все не так просто?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-009", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-010", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности." + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-011", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, по которым на конец месяца сальдо выглядит так, будто документы собраны криво и их нужно перепроверить." + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-012", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие у нас зависшие авансы или предоплаты уже давно пора либо закрыть, либо хотя бы проверить - это уже не просто вопрос времени?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-013", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "По каким контрагентам мы можем заметить такую картину: оплачено меньше, чем отгружено, и это явно требует вмешательства бухгалтера." + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-014", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие незакрытые документы по договорам у нас уже давно пора проверить - это грозит серьезными проблемами?" + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + }, + { + "case_id": "AUTO-015", + "scenario_tag": "qwen_seed_general", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться." + } + ], + "expected_hints": { + "expected_reply_type": null, + "expected_degraded_to": null + } + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-pIsvZJjub9.json b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-pIsvZJjub9.json new file mode 100644 index 0000000..b587256 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-pIsvZJjub9.json @@ -0,0 +1,238 @@ +{ + "suite_id": "assistant_autogen_runtime_job-pIsvZJjub9", + "suite_version": "0.1.0", + "schema_version": "assistant_autogen_runtime_v0_1", + "scenario_count": 19, + "case_ids": [ + "AUTO-001", + "AUTO-002", + "AUTO-003", + "AUTO-004", + "AUTO-005", + "AUTO-006", + "AUTO-007", + "AUTO-008", + "AUTO-009", + "AUTO-010", + "AUTO-011", + "AUTO-012", + "AUTO-013", + "AUTO-014", + "AUTO-015", + "AUTO-016", + "AUTO-017", + "AUTO-018", + "AUTO-019" + ], + "cases": [ + { + "case_id": "AUTO-001", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "questions" + } + ] + }, + { + "case_id": "AUTO-002", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "По каким покупателям у нас долгий хвост на конец месяца, и это уже больше похоже на системную проблему с цепочкой взаиморасчетов?" + } + ] + }, + { + "case_id": "AUTO-003", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "йо По каким поставщикам у нас на конец месяца остались хвосты, которые уже не похожи на обычную задержку документов, а выглядят как реальная проблема в цепочке?" + } + ] + }, + { + "case_id": "AUTO-004", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "слушай Где по покупателям у нас висит история \"отгрузили - денег нет - закрытия нет\", и по каким контрагентам это уже требует ручной проверки?" + } + ] + }, + { + "case_id": "AUTO-005", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "без воды?" + } + ] + }, + { + "case_id": "AUTO-006", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "подскажи плиз Покажи контрагентов, по которым сальдо у нас, скорее всего, не совпадет с их актом сверки, если его запросить прямо сейчас. по факту" + } + ] + }, + { + "case_id": "AUTO-007", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "короче Где у нас есть оплаты, но не хватает документов, которые должны были закрыть взаиморасчеты?" + } + ] + }, + { + "case_id": "AUTO-008", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "и коротко?" + } + ] + }, + { + "case_id": "AUTO-009", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "мож По каким контрагентам, наоборот, документы есть, а нормального закрытия оплатами не видно?" + } + ] + }, + { + "case_id": "AUTO-010", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "прям сейчас?" + } + ] + }, + { + "case_id": "AUTO-011", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "а ну-ка Есть ли такие зависшие авансы, которые уже давно надо было либо закрыть, либо хотя бы перепроверить руками?" + } + ] + }, + { + "case_id": "AUTO-012", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "за весь период?" + } + ] + }, + { + "case_id": "AUTO-013", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "йо Какие реализации на конец периода выглядят так, будто они зависли и будут портить картину по выручке, если их не проверить заранее?" + } + ] + }, + { + "case_id": "AUTO-014", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "слушай По каким отгрузкам видно, что проблема не просто в том, что клиент не оплатил, а в том, что сама связка документов собрана криво?" + } + ] + }, + { + "case_id": "AUTO-015", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "подскажи плиз Покажи реализации, где хвост выглядит особенно неприятно: сумма не маленькая, возраст хвоста уже заметный, и при этом не видно нормального завершения цепочки. по факту" + } + ] + }, + { + "case_id": "AUTO-016", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "короче Где по 90/62 история похожа на \"вроде все проведено, но если копнуть, закрытие держится на кривой связке\"?" + } + ] + }, + { + "case_id": "AUTO-017", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "мож Есть ли случаи, где реализация попала в период, а подтверждающие документы или оплата до сих пор живут в какой-то полуразобранной логике?" + } + ] + }, + { + "case_id": "AUTO-018", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "а ну-ка По каким продажам на конец месяца видно, что бухгалтер потом будет долго распутывать, почему все это не сошлось нормально?" + } + ] + }, + { + "case_id": "AUTO-019", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "йо Какие банковские движения выглядят так, будто выписка есть, а нормального отражения в учете под ней не хватает?" + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-tvH0FxMgzD.json b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-tvH0FxMgzD.json new file mode 100644 index 0000000..5482409 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-tvH0FxMgzD.json @@ -0,0 +1,190 @@ +{ + "suite_id": "assistant_autogen_runtime_job-tvH0FxMgzD", + "suite_version": "0.1.0", + "schema_version": "assistant_autogen_runtime_v0_1", + "scenario_count": 15, + "case_ids": [ + "AUTO-001", + "AUTO-002", + "AUTO-003", + "AUTO-004", + "AUTO-005", + "AUTO-006", + "AUTO-007", + "AUTO-008", + "AUTO-009", + "AUTO-010", + "AUTO-011", + "AUTO-012", + "AUTO-013", + "AUTO-014", + "AUTO-015" + ], + "cases": [ + { + "case_id": "AUTO-001", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Кому из контрагентов мы уже месяц отдаем товары, но на счетах все еще красуется минусовое сальдо - это реально зеленый свет для ручного вмешательства?" + } + ] + }, + { + "case_id": "AUTO-002", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?" + } + ] + }, + { + "case_id": "AUTO-003", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, по которым сальдо у нас выглядит так, будто оно врет - ну точно не совпадает с тем, что они нам прислали. Это уже критично для сверки." + } + ] + }, + { + "case_id": "AUTO-004", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Сколько заказчиков у нас на этот момент могут считаться долгожителями по своим задолженностям?" + } + ] + }, + { + "case_id": "AUTO-005", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "В каких случаях мы видим ситуацию, когда документы есть, а денег - нет и пока не предвидится?" + } + ] + }, + { + "case_id": "AUTO-006", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие контрагенты висят с закрытыми отгрузками, но с открытыми документами оплаты, что явно выглядит как кейс для ручной проверки?" + } + ] + }, + { + "case_id": "AUTO-007", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, у которых есть неоплаченные задолженности по договорам на конец месяца - это уже красный свет для бухгалтера." + } + ] + }, + { + "case_id": "AUTO-008", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "По каким заказчикам мы можем выделить непростую картину: сальдо нулевое, а история платежей явно говорит о том, что все не так просто?" + } + ] + }, + { + "case_id": "AUTO-009", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?" + } + ] + }, + { + "case_id": "AUTO-010", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности." + } + ] + }, + { + "case_id": "AUTO-011", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, по которым на конец месяца сальдо выглядит так, будто документы собраны криво и их нужно перепроверить." + } + ] + }, + { + "case_id": "AUTO-012", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие у нас зависшие авансы или предоплаты уже давно пора либо закрыть, либо хотя бы проверить - это уже не просто вопрос времени?" + } + ] + }, + { + "case_id": "AUTO-013", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "По каким контрагентам мы можем заметить такую картину: оплачено меньше, чем отгружено, и это явно требует вмешательства бухгалтера." + } + ] + }, + { + "case_id": "AUTO-014", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Какие незакрытые документы по договорам у нас уже давно пора проверить - это грозит серьезными проблемами?" + } + ] + }, + { + "case_id": "AUTO-015", + "scenario_tag": "autogen_runtime", + "question_type": "direct", + "broadness_level": "medium", + "turns": [ + { + "user_message": "Покажи контрагентов, чьи заказы на отгрузку еще не оплачены, но сальдо уже отрицательное - это явный признак того, что нужно вмешаться." + } + ] + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/data/eval_cases/eval-GmILXRUMgZ.report.json b/llm_normalizer/data/eval_cases/eval-GmILXRUMgZ.report.json new file mode 100644 index 0000000..c66956c --- /dev/null +++ b/llm_normalizer/data/eval_cases/eval-GmILXRUMgZ.report.json @@ -0,0 +1,137 @@ +{ + "run_id": "eval-GmILXRUMgZ", + "timestamp": "2026-04-11T12:28:27.408Z", + "mode": "single-pass-strict", + "use_mock": true, + "prompt_version": "normalizer_v2_0_2", + "schema_version": "v2_0_2", + "dataset": { + "source": "inline_raw_questions", + "file": null, + "raw_questions_count": 3 + }, + "cases_total": 3, + "metrics": { + "schema_validation_pass_rate": 100, + "scope_detection_accuracy": null, + "scope_in_scope_rate": 33.33, + "multi_intent_detected_rate": 0, + "clarification_required_rate": 0, + "avg_fragments_per_message": 1, + "out_of_scope_fragment_rate": 33.33, + "routed_fragment_rate": 66.67, + "no_route_fragment_rate": 33.33, + "route_resolution_accuracy": null, + "no_route_precision": null, + "false_no_route_rate": null, + "execution_state_consistency_rate": 66.67, + "executable_with_soft_assumptions_rate": 100, + "soft_assumption_used_fragment_rate": 100, + "clarification_precision": null, + "clarification_recall": null, + "false_clarification_rate": null + }, + "budget": { + "requests_total": 0, + "retries_used": 0 + }, + "clarification_eval": { + "labeled_cases": 0, + "true_positive": 0, + "false_positive": 0, + "false_negative": 0 + }, + "route_eval": { + "labeled_cases": 0, + "correct_cases": 0, + "expected_routed_cases": 0, + "no_route_true_positive": 0, + "no_route_false_positive": 0 + }, + "scope_eval": { + "labeled_cases": 0, + "correct_cases": 0 + }, + "execution_state_eval": { + "checks_total": 3, + "checks_passed": 2 + }, + "route_distribution": { + "hybrid_store_plus_live": 1, + "no_route": 1, + "batch_refresh_then_store": 1 + }, + "fallback_distribution": { + "none": 1, + "out_of_scope": 1, + "clarification": 1 + }, + "results": [ + { + "case_id": "BQ-001", + "raw_question": "Проверь хвосты по поставщикам и разложи цепочку", + "validation_passed": true, + "message_in_scope": true, + "scope_confidence": "high", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 1, + "out_of_scope_fragments": 0, + "unclear_fragments": 0, + "fallback_type": "none", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 1, + "trace_id": "-Xq2kvvsT5YnbS", + "request_count_for_case": 0 + }, + { + "case_id": "BQ-002", + "raw_question": "Как вообще по ФСБУ", + "validation_passed": true, + "message_in_scope": false, + "scope_confidence": "low", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 0, + "out_of_scope_fragments": 1, + "unclear_fragments": 0, + "fallback_type": "out_of_scope", + "predicted_route_status": "no_route", + "expected_route_status": null, + "predicted_no_route_reason": "out_of_scope", + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 0, + "trace_id": "LOJZh5gvT38Lh8", + "request_count_for_case": 0 + }, + { + "case_id": "BQ-003", + "raw_question": "Покажи топ рисков за июнь 2020", + "validation_passed": true, + "message_in_scope": false, + "scope_confidence": "low", + "contains_multiple_tasks": false, + "fragments_total": 1, + "in_scope_fragments": 0, + "out_of_scope_fragments": 0, + "unclear_fragments": 1, + "fallback_type": "clarification", + "predicted_route_status": "routed", + "expected_route_status": null, + "predicted_no_route_reason": null, + "expected_no_route_reason": null, + "predicted_clarification_required": false, + "expected_clarification_required": null, + "executable_with_soft_assumptions_fragments": 0, + "trace_id": "33PFaWdNYgR-i0", + "request_count_for_case": 0 + } + ] +} \ No newline at end of file