From 351993430f03df42f468cfcc705537a4ddeafb1f Mon Sep 17 00:00:00 2001 From: dctouch Date: Sat, 11 Apr 2026 19:45:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=93=D0=9B=D0=9E=D0=91=D0=90=D0=9B=D0=AC?= =?UTF-8?q?=D0=9D=D0=AB=D0=99=20=D0=A0=D0=95=D0=A4=D0=90=D0=9A=D0=A2=D0=9E?= =?UTF-8?q?=D0=A0=D0=98=D0=9D=D0=93=20=D0=90=D0=A0=D0=A5=D0=98=D0=A2=D0=95?= =?UTF-8?q?=D0=9A=D0=A2=D0=A3=D0=A0=D0=AB=20-=20=D0=A0=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=20=20Stage=203.6=20=D0=A3=D1=81=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=20=D0=BE=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=20data-scope=20=D0=B4=D0=BB=D1=8F=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=81=D0=BB=D0=B5=D0=BD=D0=B3=D0=B0?= =?UTF-8?q?.=20=D0=A3=D0=B1=D1=80=D0=B0=D0=BD=D0=B0=20=D1=88=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20soft-refusal?= =?UTF-8?q?=20=D0=B2=20limited-=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=D1=85.?= =?UTF-8?q?=20=D0=9F=D0=BE=D0=B4=D1=80=D0=B5=D0=B7=D0=B0=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D0=BD=D0=B0=D1=8E=20=D0=B6?= =?UTF-8?q?=D0=B8=D1=80=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D1=82=D1=80-=D1=8D=D0=BA=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TECH/1CLLMARCH-FACT.md | 37 ++ llm_normalizer/backend/dist/routes/eval.js | 31 +- .../dist/services/addressFilterExtractor.js | 461 +++++------------ .../dist/services/addressQueryService.js | 92 +++- .../backend/dist/services/answerComposer.js | 77 ++- .../backend/dist/services/assistantService.js | 237 +++++++-- llm_normalizer/backend/src/routes/eval.ts | 32 +- .../src/services/addressFilterExtractor.ts | 469 +++++------------- .../src/services/addressQueryService.ts | 108 +++- .../backend/src/services/answerComposer.ts | 98 +++- .../backend/src/services/assistantService.ts | 237 +++++++-- .../tests/addressQueryRuntimeM23.test.ts | 22 +- .../tests/assistantLivingRouter.test.ts | 105 ++++ .../tests/assistantSoftPolicyReply.test.ts | 180 +++++++ .../backend/tests/evalAsyncJobSync.test.ts | 74 +++ ...istant_autogen_runtime_job-dpHXAu3U4-.json | 190 +++++++ ...istant_autogen_runtime_job-wH3kvBePAs.json | 190 +++++++ .../eval_cases/eval-Mu-hfNaOlK.report.json | 112 +++++ .../src/components/AutoRunsHistoryPanel.tsx | 61 ++- llm_normalizer/frontend/src/styles.css | 77 +++ 20 files changed, 2060 insertions(+), 830 deletions(-) create mode 100644 llm_normalizer/backend/tests/assistantSoftPolicyReply.test.ts create mode 100644 llm_normalizer/backend/tests/evalAsyncJobSync.test.ts create mode 100644 llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-dpHXAu3U4-.json create mode 100644 llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-wH3kvBePAs.json create mode 100644 llm_normalizer/data/eval_cases/eval-Mu-hfNaOlK.report.json diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index 98a31a6..ea45fba 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -2416,6 +2416,43 @@ Implemented in current pass (Stage 3.4 deep/living soft-refusal boundary widenin - Stage 3 focused suite (+boundary): `12` files / `348` tests passed. - Type build: `npm --prefix llm_normalizer/backend run build` passed. +Implemented in current pass (Stage 3.5 evidence-first soft policy reply): +1. Added soft policy renderer for weak deep-policy envelopes: + - For weak `broad_partial` and clarification/no-grounded envelopes, final user reply now uses concise non-sectional soft format (`Коротко`, `Что уже проверено`, `Что пока не доказано`, `Что могу сделать сейчас`). + - Prevents repeated rigid section template in weak-evidence responses while preserving actionable next steps. +2. Added explicit policy gate for soft rendering: + - `shouldUseSoftPolicyReply(...)` evaluates mode + coverage gaps + weak evidence signals (`broad_query_detected`, `minimum_evidence_failed`, low confidence, critical limitation reason codes). + - Keeps structured sectioned renderer for strong grounded envelopes. +3. Preserved strong answer path: + - `focused_grounded` / high-confidence cases continue to use structured policy layout with explicit sections. +4. Added regression coverage: + - New `assistantSoftPolicyReply.test.ts`: + - weak broad-partial -> soft non-template reply; + - strong grounded -> structured sectioned reply. +5. Validation snapshot: + - Stage 3 focused suite (+soft policy): `13` files / `350` tests passed. + - Type build: `npm --prefix llm_normalizer/backend run build` passed. + +Implemented in current pass (Stage 3.6 route arbitration hardening + followup isolation): +1. Isolated standalone address topics from stale followup carryover: + - Added standalone-topic detector (`hasStandaloneAddressTopicSignal`) and applied it in followup signal/carryover gates. + - Explicit standalone requests with their own anchor (`date/account/object`) no longer inherit previous address followup context by default. +2. Hardened orchestration for real regression cases: + - Added explicit open-contract lookup signal guard (`hasOpenContractsAddressSignal`) to keep `list_open_contracts` style requests in address lane even with stale deep context. + - Extended aggregate fallback arbitration so stale followup context cannot pin standalone aggregate queries in address lane (`aggregate_analytics_signal_fallback_to_deep` remains allowed). +3. Improved data-scope slang detection resilience: + - Added direct slang lead detection for queries like `по каким конторам можем общаться` / `какая база подрублена`. + - Preserved deterministic chat/data-scope routing despite aggressive predecompose rewrites. +4. Reduced limited-reply template repetition: + - Reworked unsupported/aggregate limited phrasing in `composeLimitedReply(...)` to softer non-rigid variants. + - Suppressed redundant `Сигнал запроса` line for unsupported aggregate envelopes. +5. Reduced dictionary overfitting pressure in filter extraction: + - Shrunk `COUNTERPARTY_TOKEN_NOISE` to compact core stopwords. + - Added pattern-based filler/slang/profanity detector (`isCounterpartyFillerToken`) instead of expanding static token lists. +6. Validation snapshot: + - Targeted regression pack: `4` files / `318` tests passed (`assistantLivingRouter`, `assistantLivingChatMode`, `addressQueryRuntimeM23`, `assistantSoftPolicyReply`). + - Type build: `npm --prefix llm_normalizer/backend run build` passed. + Acceptance (Stage 3): 1. LLM outputs strictly validated schema for extraction/decomposition (no free-form). 2. Deterministic guards can block or downgrade answers when evidence insufficient. diff --git a/llm_normalizer/backend/dist/routes/eval.js b/llm_normalizer/backend/dist/routes/eval.js index 0f5f380..2edc415 100644 --- a/llm_normalizer/backend/dist/routes/eval.js +++ b/llm_normalizer/backend/dist/routes/eval.js @@ -3,7 +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.__evalRouteAsyncTestUtils = exports.__evalRouteTestUtils = void 0; exports.buildEvalRouter = buildEvalRouter; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); @@ -153,6 +153,10 @@ exports.__evalRouteTestUtils = { splitQuestionCandidate, normalizeRuntimeQuestions }; +exports.__evalRouteAsyncTestUtils = { + readReportedCaseIds, + syncJobWithSessions +}; function normalizeCaseIds(value) { if (!Array.isArray(value)) { return undefined; @@ -296,10 +300,28 @@ function readSessionConversation(runId, caseId) { return []; } } +function readReportedCaseIds(job) { + const output = new Set(); + const reportRecord = toRecord(job.report); + const reportResults = toArray(reportRecord?.results) + .map((item) => toRecord(item)) + .filter((item) => item !== null); + for (const result of reportResults) { + const caseId = toStringSafe(result.case_id); + if (!caseId) + continue; + output.add(caseId); + } + return output; +} +function isTerminalCaseStatus(status) { + return status === "completed" || status === "failed"; +} function syncJobWithSessions(job) { if (!job.run_id || !job.eval_target.startsWith("assistant_")) { return; } + const reportCaseIds = readReportedCaseIds(job); let completed = 0; let hasRunning = false; for (const item of job.cases) { @@ -307,11 +329,16 @@ function syncJobWithSessions(job) { item.messages = messages; const assistantMessages = messages.filter((entry) => entry.role === "assistant").length; const userMessages = messages.filter((entry) => entry.role === "user").length; - if (assistantMessages >= item.turns_total && item.turns_total > 0) { + const reportMarkedDone = reportCaseIds.has(item.case_id); + if ((assistantMessages >= item.turns_total && item.turns_total > 0) || reportMarkedDone) { item.status = "completed"; completed += 1; continue; } + if (isTerminalCaseStatus(item.status) && isTerminalCaseStatus(job.status)) { + completed += 1; + continue; + } if (userMessages > 0 || messages.length > 0) { item.status = "running"; hasRunning = true; diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 8fe0329..7e93dc1 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -26,6 +26,82 @@ const MONTH_PERIOD_NUMERIC_YEAR_MONTH_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for const MONTH_PERIOD_NAME_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*([a-zа-яё]+)\s+(20\d{2})(?:\s*г(?:од|ода|\\.)?)?(?=$|[\s,.;:!?()\-])/iu; const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu; const DOC_SIGNAL_PATTERN = "(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)"; +const COUNTERPARTY_TOKEN_NOISE = new Set([ + "за", + "с", + "по", + "у", + "на", + "и", + "или", + "в", + "к", + "год", + "года", + "г", + "year", + "кто", + "что", + "где", + "когда", + "сколько", + "почему", + "зачем", + "какой", + "какая", + "какие", + "каких", + "каким", + "мы", + "нам", + "нас", + "есть", + "можно", + "могу", + "можем", + "нет", + "покажи", + "показать", + "скажи", + "выведи", + "show", + "list", + "контра", + "контре", + "контрагент", + "компания", + "организация", + "client", + "customer", + "supplier", + "vendor", + "partner", + "company", + "counterparty" +]); +function isCounterpartyFillerToken(token) { + const normalized = String(token ?? "").trim().toLowerCase(); + if (!normalized) { + return true; + } + if (/^(?:пл[сз]|пж|пжлст|pls|please|пожалуйста)$/iu.test(normalized)) { + return true; + } + if (/^(?:бл[яе]|блять|нах|нахуй|епт|ёпт|епта)$/iu.test(normalized)) { + return true; + } + if (/^(?:док(?:и|ам|ами|умент(?:ы|ов)?)?|docs?|docy|doci|doki|dokument(?:y|ov|am|a)?)$/iu.test(normalized)) { + return true; + } + if (/^(?:pokazh?|pokazhi|pokaji|pokezh|kakie|kakoi|kakaya|est|za|po|na|s|vse|all|poka)$/iu.test(normalized)) { + return true; + } + return false; +} +function isCounterpartyNoiseToken(rawToken) { + const normalized = String(rawToken ?? "").trim().toLowerCase(); + return COUNTERPARTY_TOKEN_NOISE.has(normalized) || isCounterpartyFillerToken(normalized); +} function textMojibakeScore(value) { const source = String(value ?? ""); const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; @@ -425,101 +501,17 @@ function extractLooseByAnchorValue(text) { if (!token) { return undefined; } - const lowered = token.toLowerCase(); - const stopWords = new Set([ - "какой", - "какая", - "какие", - "каких", - "каким", - "какими", - "каком", - "кто", - "что", - "мы", - "видим", - "контрагенту", - "контрагента", - "контрагентам", - "контре", - "компании", - "компанию", - "организации", - "организацию", - "поставщику", - "поставщика", - "поставщикам", - "клиенту", - "клиента", - "клиентам", - "покупателю", - "покупателя", - "покупателям", - "заказчикам", - "партнеру", - "партнера", - "договору", - "договора", - "контракту", - "контракта", - "счету", - "счёту", - "дате", - "периоду", - "период", - "есть", - "же", - "сводные", - "сводный", - "сводная", - "сводную", - "сводном", - "сводного", - "сводному", - "неуказанному", - "неуказанный", - "неуказанная", - "неуказанное", - "неуказанному", - "указанному", - "указанный", - "указанная", - "указанное", - "объекту", - "объект", - "документам", - "документами", - "докам", - "взаиморасчетам", - "взаиморасчётам", - "теперь", - "сейчас", - "вернись", - "вернуться", - "вернуть", - "раскрой", - "раскрыть", - "раскройте", - "связанный", - "связанные", - "связанных", - "связанным", - "связанному", - "related", - "linked", - "нему", - "ней", - "нее", - "ним", - "этому", - "тому", - "этомуже", - "томуже" - ]); - if (stopWords.has(lowered)) { + const normalizedToken = cleanupAnchorValue(token); + if (!normalizedToken) { return undefined; } - return token; + if (!hasStrongCounterpartyTokenShape(normalizedToken)) { + return undefined; + } + if (!isLikelyCounterpartyToken(normalizedToken)) { + return undefined; + } + return normalizedToken; } function extractContractTokenHeuristic(text) { const source = String(text ?? ""); @@ -545,206 +537,20 @@ function extractContractTokenHeuristic(text) { } function isLikelyCounterpartyToken(rawToken) { const token = String(rawToken ?? "").trim(); - const lowered = token.toLowerCase(); - if (!token || token.length < 2) { + if (!token || token.length < 3) { return false; } + const lowered = token.toLowerCase(); if (/^\d+$/.test(lowered)) { return false; } if (/^(?:19|20)\d{2}$/.test(lowered)) { return false; } - const stopWords = new Set([ - "за", - "с", - "по", - "у", - "на", - "и", - "или", - "какие", - "какой", - "какая", - "какое", - "каких", - "каким", - "какими", - "каком", - "какому", - "какую", - "кто", - "что", - "чего", - "где", - "когда", - "почему", - "зачем", - "сколько", - "чьи", - "чья", - "чей", - "чью", - "мы", - "видим", - "самый", - "самая", - "самое", - "самые", - "крупный", - "крупная", - "крупное", - "крупные", - "жирный", - "жирная", - "жирное", - "жирные", - "больше", - "меньше", - "платит", - "платят", - "прогноз", - "forecast", - "план", - "плана", - "ндс", - "vat", - "налог", - "оплата", - "оплаты", - "платеж", - "платёж", - "платежа", - "платежи", - "денег", - "деньги", - "объем", - "объём", - "док", - "доки", - "документ", - "документы", - "документов", - "документами", - "документу", - "документе", - "документа", - "документах", - "докам", - "доками", - "банк", - "банковские", - "операции", - "платежи", - "платеж", - "платёж", - "контрагент", - "контрагенту", - "контрагента", - "контрагентам", - "компания", - "компании", - "организация", - "организации", - "поставщикам", - "клиентам", - "покупателям", - "заказчикам", - "аванс", - "авансы", - "проблемный", - "проблемные", - "проблемным", - "закрытия", - "закрыть", - "закрыты", - "год", - "года", - "г", - "плс", - "pls", - "пж", - "пжлст", - "пожалуйста", - "бля", - "блять", - "епт", - "ёпт", - "епта", - "нах", - "нахуй", - "есть", - "же", - "сводные", - "сводный", - "сводная", - "сводную", - "сводном", - "сводного", - "сводному", - "неуказанному", - "неуказанный", - "неуказанная", - "неуказанное", - "указанному", - "указанный", - "указанная", - "указанное", - "объекту", - "объект", - "покеж", - "покажи", - "скажи", - "показать", - "выведи", - "show", - "list", - "please", - "теперь", - "сейчас", - "вернись", - "вернуться", - "вернуть", - "раскрой", - "раскрыть", - "раскройте", - "нему", - "ней", - "ним", - "этому", - "тому", - "этомуже", - "томуже", - "vse", - "all", - "kakie", - "kakoi", - "est", - "za", - "po", - "na", - "s", - "poka", - "pokaji", - "skazhi", - "pokazhi", - "pokazh", - "pokezh", - "doki", - "doky", - "dokument", - "dokumenty", - "documents", - "docs", - "связанный", - "связанные", - "связанных", - "связанным", - "связанному", - "related", - "linked" - ]); - return !stopWords.has(lowered); + if (/^(?:(?:19|20)?\d{2})(?:-?й)?(?:г|год|года)?$/iu.test(lowered)) { + return false; + } + return !isCounterpartyNoiseToken(lowered); } function isLowQualityCounterpartyAnchorValue(rawValue) { const value = String(rawValue ?? "") @@ -816,61 +622,25 @@ function hasDocsOrBankSignal(text) { const lowered = String(text ?? "").toLowerCase(); return new RegExp(`(?:${DOC_SIGNAL_PATTERN}|банк|выписк|списан|поступлен|платеж|платёж|оплат|transactions?|bank\\s+ops|bank\\s+operations?|payment|payments?|platezh|oplata)`, "iu").test(lowered); } -function extractCounterpartyFromFreeTextHeuristic(text) { - if (!hasDocsOrBankSignal(text)) { - return undefined; +function hasStrongCounterpartyTokenShape(token) { + const source = String(token ?? "").trim(); + if (!source) { + return false; } - const tokens = String(text ?? "") - .split(/[^\p{L}\p{N}._-]+/u) - .map((item) => item.trim()) - .filter((item) => item.length > 0); - if (tokens.length === 0) { - return undefined; + if (/[0-9]/u.test(source) || /[._/-]/u.test(source)) { + return true; } - const monthTokens = [ - "янв", - "фев", - "мар", - "апр", - "май", - "июн", - "июл", - "авг", - "сен", - "сент", - "окт", - "ноя", - "дек", - "january", - "february", - "march", - "april", - "may", - "june", - "july", - "august", - "september", - "october", - "november", - "december" - ]; - for (const token of tokens) { - const lowered = token.toLowerCase(); - if (!isLikelyCounterpartyToken(lowered)) { - continue; - } - if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) { - continue; - } - if (monthTokens.some((prefix) => lowered.startsWith(prefix))) { - continue; - } - if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) { - continue; - } - return token; + if (/[A-ZА-ЯЁ]/u.test(source)) { + return true; } - return undefined; + // Keep only compact lowercase slang aliases (e.g. "svk"), not arbitrary words. + if (/^[a-z]{2,6}$/u.test(source)) { + return true; + } + if (/^[а-яё]+$/iu.test(source) && source.length <= 4) { + return true; + } + return false; } function extractImplicitCounterpartyValue(text) { const input = String(text ?? ""); @@ -878,7 +648,7 @@ function extractImplicitCounterpartyValue(text) { const beforeDocsMatch = input.match(beforeDocsPattern); if (beforeDocsMatch) { const candidate = String(beforeDocsMatch[1] ?? "").trim(); - if (isLikelyCounterpartyToken(candidate)) { + if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) { return candidate; } } @@ -886,7 +656,7 @@ function extractImplicitCounterpartyValue(text) { const afterDocsMatch = input.match(afterDocsPattern); if (afterDocsMatch) { const candidate = String(afterDocsMatch[1] ?? "").trim(); - if (isLikelyCounterpartyToken(candidate)) { + if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) { return candidate; } } @@ -932,6 +702,9 @@ function extractLeadingCounterpartyTokenHeuristic(text) { ]; for (const token of tokens.slice(0, 3)) { const lowered = token.toLowerCase(); + if (!hasStrongCounterpartyTokenShape(token)) { + continue; + } if (!isLikelyCounterpartyToken(lowered)) { continue; } @@ -1048,16 +821,6 @@ function extractAddressFilters(userMessage, intent) { warnings.push("counterparty_anchor_derived_from_implicit_phrase"); } } - if (!filters.counterparty && - (intent === "list_documents_by_counterparty" || - intent === "bank_operations_by_counterparty" || - intent === "list_contracts_by_counterparty")) { - const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text); - if (heuristicCounterparty) { - filters.counterparty = cleanupAnchorValue(heuristicCounterparty); - warnings.push("counterparty_anchor_derived_from_free_text_heuristic"); - } - } if (!filters.counterparty && (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index d06b0f7..3839df8 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -930,6 +930,15 @@ function buildLimitedOffers(input) { offers.push("пример: «покажи документы по договору <номер> за 2020 год»"); } } + if (input.intent === "list_receivables_counterparties") { + offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); + } + else if (input.intent === "list_payables_counterparties") { + offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76"); + } + else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") { + offers.push("показать незакрытые договоры и хвосты взаиморасчетов на дату"); + } if (counterparty) { offers.push(`показать документы и платежи по контрагенту ${counterparty}`); } @@ -956,38 +965,101 @@ function buildLimitedOffers(input) { } return Array.from(new Set(offers)).slice(0, 3); } +function buildLimitedIntentSignalLine(input) { + const byIntent = { + list_documents_by_counterparty: "Сигнал запроса: нужен срез документов/платежей по контрагенту.", + list_documents_by_contract: "Сигнал запроса: нужен срез документов/платежей по договору.", + bank_operations_by_counterparty: "Сигнал запроса: нужен срез банковских операций по контрагенту.", + bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.", + open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.", + list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", + list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", + list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов." + }; + const byShape = { + AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.", + DOCUMENT_LIST: "Сигнал запроса: список документов/операций.", + OBJECT_LOOKUP: "Сигнал запроса: поиск конкретных объектов.", + VERIFY_FACTUAL: "Сигнал запроса: проверка фактического состояния по данным.", + COMPOUND_FACTUAL_QUERY: "Сигнал запроса: комбинированная проверка взаимосвязанных фактов." + }; + return byIntent[input.intent] ?? byShape[input.shape.shape] ?? null; +} +function hasAggregateLimitedSignal(input) { + if (input.shape.shape === "AGGREGATE_LOOKUP") { + return true; + } + if (input.intent === "counterparty_population_and_roles" || + input.intent === "counterparty_activity_lifecycle" || + input.intent === "contract_usage_overview" || + input.intent === "supplier_payouts_profile" || + input.intent === "customer_revenue_and_payments" || + input.intent === "contract_usage_and_value") { + return true; + } + return /(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin|year)/iu.test(String(input.reason ?? "")); +} function composeLimitedReply(input) { const reason = normalizeLimitedReason(input.reason); const headingSeed = `${input.category}|${input.shape.shape}|${reason}`; + const aggregateLimitedSignal = hasAggregateLimitedSignal({ + shape: input.shape, + intent: input.intent, + reason: input.reason + }); const heading = input.category === "empty_match" ? pickDeterministicVariant(headingSeed, [ "По текущим условиям в доступном срезе данных совпадений не нашлось.", - "В текущем срезе данных по этому запросу совпадения не найдены." + "В текущем срезе данных по этому запросу совпадения не найдены.", + "По заданным фильтрам в текущем срезе совпадений пока нет." ]) : input.category === "missing_anchor" ? pickDeterministicVariant(headingSeed, [ "Чтобы ответ был точным, нужно чуть сильнее заякорить запрос.", - "Запрос понятен, но для надежного ответа не хватает опорного ориентира." + "Запрос понятен, но для надежного ответа не хватает опорного ориентира.", + "Вопрос по смыслу ясен, но пока не хватает конкретной опоры для выборки." ]) : input.category === "recipe_visibility_gap" ? pickDeterministicVariant(headingSeed, [ "Запрос понятен, но текущий сценарий выборки не дает нужной детализации.", - "Смысл запроса ясен, но в этом контуре не хватает глубины выборки." + "Смысл запроса ясен, но в этом контуре не хватает глубины выборки.", + "Сценарий запроса корректный, но текущая витрина не дает нужной детализации." ]) : input.category === "unsupported" ? pickDeterministicVariant(headingSeed, [ - "По этому вопросу в текущем адресном контуре пока нет надежного маршрута ответа.", - "Сейчас в адресном режиме такой сценарий не закрыт без риска ошибочного вывода." + "Сейчас не дам прямой адресный ответ, чтобы не ошибиться в выводах.", + "В текущем адресном контуре этот запрос лучше не закрывать «в лоб» — риск неверной трактовки высок.", + "Для такого формата запроса нужен более широкий аналитический контур, иначе ответ будет ненадежным." ]) : "Не удалось завершить проверку в адресном режиме."; + const reasonSeed = `${headingSeed}|reason`; const reasonLine = input.category === "unsupported" - ? "Коротко: сценарий пока не покрыт текущими адресными маршрутами." + ? aggregateLimitedSignal + ? pickDeterministicVariant(reasonSeed, [ + "Это агрегатный/сравнительный вопрос: без расширенного анализа здесь легко дать ложную метрику.", + "Запрос про сводную аналитику или ранжирование, поэтому в address-контуре ответ сейчас будет ненадежным.", + "Нужна расширенная аналитическая обработка: адресный режим в этом кейсе не гарантирует корректный расчет." + ]) + : pickDeterministicVariant(reasonSeed, [ + "Сценарий пока не закрыт текущими адресными маршрутами без потери точности.", + "Для этого запроса пока нет надежного ответа внутри текущего address-контура." + ]) : input.category === "missing_anchor" - ? "Коротко: не хватает конкретного ориентира (контрагент, договор, счет или период)." + ? pickDeterministicVariant(reasonSeed, [ + "Нужно чуть точнее заякорить запрос: не хватает конкретного ориентира (контрагент, договор, счет или период).", + "Для точного ответа нужен хотя бы один явный якорь: контрагент, договор, счет или период." + ]) : input.category === "recipe_visibility_gap" - ? "Коротко: для уверенного ответа нужен более специализированный сценарий выборки." - : `Коротко: ${reason}.`; + ? "Для уверенного ответа нужен более специализированный сценарий выборки." + : `${reason}.`; const lines = [heading, reasonLine]; + const signalLine = buildLimitedIntentSignalLine({ + intent: input.intent, + shape: input.shape + }); + if (signalLine && !(input.category === "unsupported" && aggregateLimitedSignal)) { + lines.push(signalLine); + } const scopeLine = buildLimitedScopeLine(input.filters); if (scopeLine) { lines.push(scopeLine); @@ -995,6 +1067,7 @@ function composeLimitedReply(input) { const offers = buildLimitedOffers({ category: input.category, shape: input.shape, + intent: input.intent, filters: input.filters, missingRequiredFilters: input.missingRequiredFilters, reason: input.reason, @@ -1014,6 +1087,7 @@ function buildLimitedExecutionResult(input) { reason: input.reasonText, nextStep: input.nextStep, shape: input.shape, + intent: input.intent.intent, filters: input.filters, missingRequiredFilters: input.missingRequiredFilters }), diff --git a/llm_normalizer/backend/dist/services/answerComposer.js b/llm_normalizer/backend/dist/services/answerComposer.js index 77880f0..4f469df 100644 --- a/llm_normalizer/backend/dist/services/answerComposer.js +++ b/llm_normalizer/backend/dist/services/answerComposer.js @@ -3613,6 +3613,53 @@ function renderPolicyReply(structure, context) { .filter(Boolean) .join("\n\n")); } +function shouldUseSoftPolicyReply(input) { + if (input.mode === "focused_grounded" || input.mode === "route_mismatch" || input.mode === "backend_error" || input.mode === "out_of_scope") { + return false; + } + if (input.mode === "clarification_required" || input.mode === "no_grounded" || input.mode === "empty") { + return true; + } + if (input.mode !== "broad_partial") { + return false; + } + const hasCoverageGaps = input.coverageReport.requirements_uncovered.length > 0 || + input.coverageReport.requirements_partially_covered.length > 0 || + input.coverageReport.clarification_needed_for.length > 0 || + input.coverageReport.out_of_scope_requirements.length > 0; + const weakEvidenceSignals = input.policySignals.broad_query_detected || + input.policySignals.broad_result_flag || + input.policySignals.minimum_evidence_failed || + input.aggregateEvidenceConfidence === "low" || + input.hasCriticalEvidenceLimitation || + input.limitationReasonCodes.includes("weak_source_mapping") || + input.limitationReasonCodes.includes("insufficient_detail") || + input.limitationReasonCodes.includes("missing_mechanism"); + return hasCoverageGaps || weakEvidenceSignals; +} +function renderSoftPolicyReply(input) { + const questionType = input.context?.questionType ?? "unknown"; + const shortLine = ensureSentence(buildShortSectionLine(input.structure)); + const evidenceLines = dedupeNarrativeLines(buildEvidenceSectionLines(input.structure, questionType, input.context), 3); + const limitationLines = dedupeNarrativeLines(buildLimitationsSectionLines(input.structure), 3); + const checkLines = dedupeNarrativeLines(buildChecksSectionLines(input.structure, input.context), 3); + const clarificationLines = dedupeNarrativeLines(input.structure.next_step_block.clarification_questions ?? [], 2); + const actionLines = dedupeNarrativeLines([...checkLines, ...(input.structure.next_step_block.recommended_actions ?? []), ...clarificationLines], 3); + const modeLine = input.mode === "clarification_required" + ? "Чтобы дать точный ответ, нужно уточнить несколько ориентиров." + : input.mode === "no_grounded" || input.mode === "empty" + ? "Сейчас подтвержденной опоры недостаточно для прямого вывода." + : "Есть рабочие сигналы, но часть вывода пока ограничена."; + return sanitizeUserFacingReply([ + `Коротко: ${shortLine}`, + modeLine, + evidenceLines.length > 0 ? `Что уже проверено: ${evidenceLines.join("; ")}` : "", + limitationLines.length > 0 ? `Что пока не доказано: ${limitationLines.join("; ")}` : "", + actionLines.length > 0 ? `Что могу сделать сейчас: ${actionLines.join("; ")}` : "" + ] + .filter(Boolean) + .join("\n\n")); +} function composeAssistantAnswerV11(input) { const fallbackType = fallbackFromSummary(input.routeSummary); const questionType = input.questionTypeHint ?? "unknown"; @@ -3856,12 +3903,30 @@ function composeAssistantAnswerV11(input) { missingAnchors, coverageReport: input.coverageReport }) - : renderPolicyReply(answerStructure, { - questionType, - focusDomain: focusNarrativeDomain, - anchors: anchorUsage, - userMessage: input.userMessage - }); + : shouldUseSoftPolicyReply({ + mode: guardedDecision.mode, + policySignals, + limitationReasonCodes, + aggregateEvidenceConfidence, + coverageReport: input.coverageReport, + hasCriticalEvidenceLimitation + }) + ? renderSoftPolicyReply({ + structure: answerStructure, + context: { + questionType, + focusDomain: focusNarrativeDomain, + anchors: anchorUsage, + userMessage: input.userMessage + }, + mode: guardedDecision.mode + }) + : renderPolicyReply(answerStructure, { + questionType, + focusDomain: focusNarrativeDomain, + anchors: anchorUsage, + userMessage: input.userMessage + }); return { assistant_reply: finalAssistantReply, fallback_type: guardedDecision.fallback_type, diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index eff08c9..fd5ae32 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -1044,6 +1044,27 @@ function countTokens(text) { function hasPeriodLiteral(text) { return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text); } +function hasStandaloneAddressTopicSignal(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + if (hasFollowupMarker(normalized) || hasReferentialPointer(normalized)) { + return false; + } + const hasRequestCue = /(?:^|[\s,.;:!?()\-])(?:покажи|показать|выведи|дай|найди|список|какие|какой|какая|каких|сколько|где|show|list|find|which|what)/iu.test(normalized); + if (!hasRequestCue) { + return false; + } + const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized); + if (!hasBusinessObject) { + return false; + } + const hasStructuredAnchor = hasPeriodLiteral(normalized) || + /\b\d{2}(?:[.,]\d{1,2})?\b/.test(normalized) || + /(?:альтернатива|лайсвуд|райм|ооо\s+[a-zа-яё])/iu.test(normalized); + return hasStructuredAnchor || countTokens(normalized) >= 6; +} function extractNormalizedPeriodLiteral(text) { const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/); if (monthly) { @@ -1350,6 +1371,35 @@ function buildAddressCoverageReport() { out_of_scope_requirements: [] }; } +function buildAssistantBackendErrorDebugPayload(errorMessage) { + return { + trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`, + prompt_version: "assistant_backend_error_fallback_v1", + schema_version: "assistant_backend_error_fallback_v1", + fallback_type: "unknown", + route_summary: null, + fragments: [], + requirements_extracted: [], + coverage_report: buildAddressCoverageReport(), + routes: [], + retrieval_status: [], + retrieval_results: [], + answer_grounding_check: { + status: "no_grounded_answer", + route_subject_match: true, + missing_requirements: [], + reasons: [ + `backend_error:${String(errorMessage ?? "unknown_error").slice(0, 280)}` + ], + why_included_summary: [], + selection_reason_summary: [] + }, + dropped_intent_segments: [] + }; +} +function buildAssistantBackendErrorReply() { + return "Сейчас не удалось завершить разбор из-за внутренней ошибки контуров LLM. Могу продолжить в адресном режиме: проверить документы, договоры и операции по нужному периоду или контрагенту."; +} function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) { const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded"; const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null; @@ -2214,6 +2264,9 @@ function hasAddressFollowupContextSignal(userMessage) { if (!text) { return false; } + if (hasStandaloneAddressTopicSignal(text)) { + return false; + } if (shouldHandleAsAssistantCapabilityMetaQuery(text)) { return false; } @@ -2272,6 +2325,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) ? hasAddressFollowupContextSignal(alternateMessage) : false; + const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || + (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); + if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) { + return null; + } if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) { return null; } @@ -2903,6 +2961,29 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; + const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety && + sourceAnchorQuality.anchorType === "counterparty" && + sourceAnchorQuality.quality >= 2 && + Boolean(sourceAnchorQuality.anchorValue) && + ((candidateAnchorQuality.anchorType === "counterparty" && + candidateAnchorQuality.quality >= 2 && + Boolean(candidateAnchorQuality.anchorValue) && + hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "")) || + (candidateAnchorQuality.quality < sourceAnchorQuality.quality && + hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidate))); + if (counterpartyAnchorSubstitutedByCandidate) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_anchor_substitution", + fallbackRuleHit: null, + sanitizedUserMessage + }, userMessage); + } const anchorDegradedByCandidate = sameIntentForAnchorSafety && sourceAnchorQuality.anchorType && sourceAnchorQuality.quality >= 2 && @@ -2920,27 +3001,6 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage sanitizedUserMessage }, userMessage); } - const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety && - sourceAnchorQuality.anchorType === "counterparty" && - candidateAnchorQuality.anchorType === "counterparty" && - sourceAnchorQuality.quality >= 2 && - candidateAnchorQuality.quality >= 2 && - Boolean(sourceAnchorQuality.anchorValue) && - Boolean(candidateAnchorQuality.anchorValue) && - hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? ""); - if (counterpartyAnchorSubstitutedByCandidate) { - return attachAddressPredecomposeContract({ - ...baseMeta, - attempted: true, - applied: false, - traceId: normalized?.trace_id ?? null, - llmCanonicalCandidateDetected: true, - effectiveMessage: userMessage, - reason: "normalized_fragment_rejected_anchor_substitution", - fallbackRuleHit: null, - sanitizedUserMessage - }, userMessage); - } if (fallbackCandidate) { const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? "")); const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety && @@ -3123,6 +3183,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll llmContractIntent === "unknown"; const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || + hasDataRetrievalRequestSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) || hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate); const strongDataSignalFromEffectiveMessage = hasStrongDataIntentSignal(repairedInputMessage) || @@ -3149,7 +3210,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll reason: "llm_predecompose_unsupported_mode" }; } - const hasMessageSignal = hasAnyAddressSignal; + const hasMessageSignal = hasAnyAddressSignal || strongDataSignalFromRawMessage || strongDataSignalFromEffectiveMessage; if (hasMessageSignal) { return { runAddressLane: true, @@ -3234,7 +3295,9 @@ function hasDeepAnalysisPreferenceSignal(text) { } 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 diagnosticsKeywordSignal = /(?:\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|audit|scan|\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 diagnosticsCheckVerbSignal = /(?:^|[\s,.;:!?()\-])\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c|\u043a\u0443|\u0438\u043c|\u043a\u0430)(?:$|[\s,.;:!?()\-])/iu.test(lower); + const diagnosticsSignal = diagnosticsKeywordSignal || diagnosticsCheckVerbSignal; 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); @@ -3242,13 +3305,12 @@ function hasDeepAnalysisPreferenceSignal(text) { 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 || + return lifecycleMismatchSignal || (chainSignal && lifecycleTransitionGapSignal) || expectedActualMismatchSignal || (chainSignal && diagnosticsSignal) || - (riskOrAnomalySignal && (chainSignal || closureSignal || diagnosticsSignal || closureIntentSignal)) || - (diagnosticsSignal && closureIntentSignal) || + (riskOrAnomalySignal && (chainSignal || diagnosticsSignal || lifecycleTransitionGapSignal)) || + (diagnosticsSignal && (closureSignal || closureIntentSignal)) || closureDiagnosticPhraseSignal || signalVsNoiseDiagnostic; } @@ -3257,7 +3319,7 @@ function hasDirectDeepAnalysisSignal(text) { 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); + 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\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|close\s+period|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized); } function hasStrictDeepInvestigationCue(text) { const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); @@ -3283,6 +3345,23 @@ function hasAggregateBusinessAnalyticsSignal(text) { 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; } +function hasOpenContractsAddressSignal(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + const hasContractCue = /(?:договор|контракт|contract)/iu.test(normalized); + if (!hasContractCue) { + return false; + } + const hasOpenCue = /(?:незакрыт|не\s+закрыт|открыт|open\s+contract|open\s+item|open)/iu.test(normalized); + if (!hasOpenCue) { + return false; + } + const hasRequestCue = /(?:покажи|показать|список|какие|какой|show|list|find|на\s+дату|as\s+of)/iu.test(normalized); + const hasTemporalCue = hasPeriodLiteral(normalized) || /\b\d{4}[-/.]\d{2}[-/.]\d{2}\b/.test(normalized); + return hasRequestCue || hasTemporalCue; +} const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "list_open_contracts", "open_items_by_counterparty_or_contract", @@ -3320,10 +3399,21 @@ function resolveAssistantOrchestrationDecision(input) { hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) || hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) || hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage); + const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) || + hasStandaloneAddressTopicSignal(repairedRawUserMessage) || + hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) || + hasStandaloneAddressTopicSignal(repairedEffectiveAddressUserMessage); + const openContractsAddressSignal = hasOpenContractsAddressSignal(rawUserMessage) || + hasOpenContractsAddressSignal(repairedRawUserMessage) || + hasOpenContractsAddressSignal(effectiveAddressUserMessage) || + hasOpenContractsAddressSignal(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 llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason); + const llmRuntimeUnavailableDetected = Boolean(llmPreDecomposeReason && + /(?:openai\s+api\s+key\s+is\s+missing|api\s+key\s+is\s+missing|missing\s+api\s+key|authentication)/iu.test(llmPreDecomposeReason)); const semanticExtractionContract = llmPreDecomposeMeta?.semanticExtractionContract && typeof llmPreDecomposeMeta.semanticExtractionContract === "object" ? llmPreDecomposeMeta.semanticExtractionContract @@ -3339,7 +3429,8 @@ function resolveAssistantOrchestrationDecision(input) { hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage); const keepAddressLaneByIntent = semanticApplyCanonicalRecommended && Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || - (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))) && + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || + openContractsAddressSignal) && !strictDeepInvestigationCueDetected; const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) || hasStrongDataIntentSignal(repairedRawUserMessage) || @@ -3431,7 +3522,8 @@ function resolveAssistantOrchestrationDecision(input) { hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage)); const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected && Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || - (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))); + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || + openContractsAddressSignal); const semanticGuardHints = semanticExtractionContract?.guard_hints && typeof semanticExtractionContract.guard_hints === "object" ? semanticExtractionContract.guard_hints @@ -3452,8 +3544,14 @@ function resolveAssistantOrchestrationDecision(input) { const unsupportedIntentOrMode = (modeDetection.mode !== "address_query" && intentResolution.intent === "unknown") || llmContractMode === "unsupported"; const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane && + !llmRuntimeUnavailableDetected && unsupportedIntentOrMode && strongDataSignal && + (llmContractMode === "deep_analysis" || + !dataRetrievalSignal || + strictDeepInvestigationCueDetected || + semanticDeepInvestigationHintDetected || + aggregateBusinessAnalyticsSignal) && !preserveAddressLaneSignal && !keepAddressLaneByIntent && !supportedAddressIntentDetected && @@ -3470,21 +3568,25 @@ function resolveAssistantOrchestrationDecision(input) { 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 && + !llmRuntimeUnavailableDetected && (deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) && !keepAddressLaneByIntent && !supportedAddressIntentDetected && !vatExplainFollowupSignal && (!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed)); const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane && + !llmRuntimeUnavailableDetected && aggregateBusinessAnalyticsSignal && !keepAddressLaneByIntent && !supportedAddressIntentDetected && (!followupContext || llmContractMode === "unsupported" || semanticAggregateShapeDetected || - !semanticApplyCanonicalRecommended)); + !semanticApplyCanonicalRecommended || + standaloneAddressTopicSignal)); const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && baseToolGate?.runAddressLane && + !llmRuntimeUnavailableDetected && hasDeepSessionContinuationSignal({ rawUserMessage, repairedRawUserMessage, @@ -3591,24 +3693,29 @@ function resolveAssistantOrchestrationDecision(input) { } function hasStrongDataIntentSignal(text) { const lower = String(text ?? "").toLowerCase(); - return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower); + return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower); } function hasDataRetrievalRequestSignal(text) { const lower = compactWhitespace(String(text ?? "").toLowerCase()); if (!lower) { return false; } + const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower); + const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower); + if (hasBroadInterrogative && hasBroadBusinessObject) { + return true; + } 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); + const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bгде\b|\bпо\s+каким\b|\bпо\s+кому\b|\bу\s+кого\b|\bwhich\b|\bwho\b|\bwhere\b)/i.test(lower); if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { return false; } - const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); + const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); if (!hasRetrievalObject) { return false; } @@ -3769,6 +3876,10 @@ function hasAssistantDataScopeMetaQuestionSignal(text) { if (!normalized) { return false; } + const hasDirectSlangScopeLead = /(?:по\s+каким\s+(?:контор(?:ам|ы|а)?|кантор(?:ам|ы|а)?|компан(?:иям|ии|ию|ия)|организац(?:иям|ии|ию|ия))\s+мож(?:ем|но)\s+(?:общат|работ)|база\s+какой\s+(?:контор|компан|организац|фирм)|какая\s+база\s+(?:подключ|подруб|актив))/iu.test(normalized); + if (hasDirectSlangScopeLead) { + return true; + } 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; @@ -4816,14 +4927,56 @@ class AssistantService { extractExecutionState } }); - const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({ - payload, - runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)), - resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems), - runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)), - runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)) - }); - return turnRuntime.response; + try { + const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({ + payload, + runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)), + resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems), + runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)), + runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)) + }); + return turnRuntime.response; + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const sessionId = String(payload?.session_id ?? payload?.sessionId ?? "").trim() || `asst-${(0, nanoid_1.nanoid)(10)}`; + const ensuredSession = this.sessions.ensureSession(sessionId); + const existingAssistant = [...ensuredSession.items].reverse().find((item) => item.role === "assistant") ?? null; + if (existingAssistant) { + return { + ok: true, + session_id: sessionId, + assistant_reply: existingAssistant.text, + reply_type: existingAssistant.reply_type ?? "backend_error", + conversation_item: existingAssistant, + debug: existingAssistant.debug ?? buildAssistantBackendErrorDebugPayload(errorMessage), + conversation: cloneItems(ensuredSession.items) + }; + } + const createdAt = new Date().toISOString(); + const debugPayload = buildAssistantBackendErrorDebugPayload(errorMessage); + const assistantItem = this.sessions.appendItem(sessionId, { + message_id: `msg-${(0, nanoid_1.nanoid)(10)}`, + session_id: sessionId, + role: "assistant", + text: buildAssistantBackendErrorReply(), + reply_type: "backend_error", + created_at: createdAt, + trace_id: debugPayload.trace_id ?? null, + debug: debugPayload + }); + const sessionSnapshot = this.sessions.getSession(sessionId) ?? this.sessions.ensureSession(sessionId); + this.sessionLogger.persistSession(sessionSnapshot); + return { + ok: true, + session_id: sessionId, + assistant_reply: assistantItem.text, + reply_type: "backend_error", + conversation_item: assistantItem, + debug: debugPayload, + conversation: cloneItems(sessionSnapshot.items) + }; + } } } exports.AssistantService = AssistantService; diff --git a/llm_normalizer/backend/src/routes/eval.ts b/llm_normalizer/backend/src/routes/eval.ts index 147dac5..2c1186f 100644 --- a/llm_normalizer/backend/src/routes/eval.ts +++ b/llm_normalizer/backend/src/routes/eval.ts @@ -202,6 +202,11 @@ export const __evalRouteTestUtils = { normalizeRuntimeQuestions }; +export const __evalRouteAsyncTestUtils = { + readReportedCaseIds, + syncJobWithSessions +}; + function normalizeCaseIds(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -365,10 +370,30 @@ function readSessionConversation(runId: string, caseId: string): EvalAsyncCaseIn } } +function readReportedCaseIds(job: EvalAsyncJob): Set { + const output = new Set(); + const reportRecord = toRecord(job.report); + const reportResults = toArray(reportRecord?.results) + .map((item) => toRecord(item)) + .filter((item): item is Record => item !== null); + + for (const result of reportResults) { + const caseId = toStringSafe(result.case_id); + if (!caseId) continue; + output.add(caseId); + } + return output; +} + +function isTerminalCaseStatus(status: EvalAsyncStatus): boolean { + return status === "completed" || status === "failed"; +} + function syncJobWithSessions(job: EvalAsyncJob): void { if (!job.run_id || !job.eval_target.startsWith("assistant_")) { return; } + const reportCaseIds = readReportedCaseIds(job); let completed = 0; let hasRunning = false; for (const item of job.cases) { @@ -376,11 +401,16 @@ function syncJobWithSessions(job: EvalAsyncJob): void { item.messages = messages; const assistantMessages = messages.filter((entry) => entry.role === "assistant").length; const userMessages = messages.filter((entry) => entry.role === "user").length; - if (assistantMessages >= item.turns_total && item.turns_total > 0) { + const reportMarkedDone = reportCaseIds.has(item.case_id); + if ((assistantMessages >= item.turns_total && item.turns_total > 0) || reportMarkedDone) { item.status = "completed"; completed += 1; continue; } + if (isTerminalCaseStatus(item.status) && isTerminalCaseStatus(job.status)) { + completed += 1; + continue; + } if (userMessages > 0 || messages.length > 0) { item.status = "running"; hasRunning = true; diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index f9c1047..4bde8f9 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -36,6 +36,84 @@ const MONTH_PERIOD_NAME_YEAR_FIRST_PATTERN = /(?:^|[\s,.;:!?()\-])(?:за|for|на|in)?\s*(20\d{2})(?:\s*г(?:од|ода|\\.)?)?\s+([a-zа-яё]+)(?=$|[\s,.;:!?()\-])/iu; const DOC_SIGNAL_PATTERN = "(?:док(?:и|умент|ументы|ументов|умам|ума)|docs?|documents?|docy|doci|doki|dokument(?:y|ov|am|a)?)"; +const COUNTERPARTY_TOKEN_NOISE = new Set([ + "за", + "с", + "по", + "у", + "на", + "и", + "или", + "в", + "к", + "год", + "года", + "г", + "year", + "кто", + "что", + "где", + "когда", + "сколько", + "почему", + "зачем", + "какой", + "какая", + "какие", + "каких", + "каким", + "мы", + "нам", + "нас", + "есть", + "можно", + "могу", + "можем", + "нет", + "покажи", + "показать", + "скажи", + "выведи", + "show", + "list", + "контра", + "контре", + "контрагент", + "компания", + "организация", + "client", + "customer", + "supplier", + "vendor", + "partner", + "company", + "counterparty" +]); + +function isCounterpartyFillerToken(token: string): boolean { + const normalized = String(token ?? "").trim().toLowerCase(); + if (!normalized) { + return true; + } + if (/^(?:пл[сз]|пж|пжлст|pls|please|пожалуйста)$/iu.test(normalized)) { + return true; + } + if (/^(?:бл[яе]|блять|нах|нахуй|епт|ёпт|епта)$/iu.test(normalized)) { + return true; + } + if (/^(?:док(?:и|ам|ами|умент(?:ы|ов)?)?|docs?|docy|doci|doki|dokument(?:y|ov|am|a)?)$/iu.test(normalized)) { + return true; + } + if (/^(?:pokazh?|pokazhi|pokaji|pokezh|kakie|kakoi|kakaya|est|za|po|na|s|vse|all|poka)$/iu.test(normalized)) { + return true; + } + return false; +} + +function isCounterpartyNoiseToken(rawToken: string): boolean { + const normalized = String(rawToken ?? "").trim().toLowerCase(); + return COUNTERPARTY_TOKEN_NOISE.has(normalized) || isCounterpartyFillerToken(normalized); +} function textMojibakeScore(value: string): number { const source = String(value ?? ""); @@ -488,101 +566,17 @@ function extractLooseByAnchorValue(text: string): string | undefined { if (!token) { return undefined; } - const lowered = token.toLowerCase(); - const stopWords = new Set([ - "какой", - "какая", - "какие", - "каких", - "каким", - "какими", - "каком", - "кто", - "что", - "мы", - "видим", - "контрагенту", - "контрагента", - "контрагентам", - "контре", - "компании", - "компанию", - "организации", - "организацию", - "поставщику", - "поставщика", - "поставщикам", - "клиенту", - "клиента", - "клиентам", - "покупателю", - "покупателя", - "покупателям", - "заказчикам", - "партнеру", - "партнера", - "договору", - "договора", - "контракту", - "контракта", - "счету", - "счёту", - "дате", - "периоду", - "период", - "есть", - "же", - "сводные", - "сводный", - "сводная", - "сводную", - "сводном", - "сводного", - "сводному", - "неуказанному", - "неуказанный", - "неуказанная", - "неуказанное", - "неуказанному", - "указанному", - "указанный", - "указанная", - "указанное", - "объекту", - "объект", - "документам", - "документами", - "докам", - "взаиморасчетам", - "взаиморасчётам", - "теперь", - "сейчас", - "вернись", - "вернуться", - "вернуть", - "раскрой", - "раскрыть", - "раскройте", - "связанный", - "связанные", - "связанных", - "связанным", - "связанному", - "related", - "linked", - "нему", - "ней", - "нее", - "ним", - "этому", - "тому", - "этомуже", - "томуже" - ]); - if (stopWords.has(lowered)) { + const normalizedToken = cleanupAnchorValue(token); + if (!normalizedToken) { return undefined; } - return token; + if (!hasStrongCounterpartyTokenShape(normalizedToken)) { + return undefined; + } + if (!isLikelyCounterpartyToken(normalizedToken)) { + return undefined; + } + return normalizedToken; } function extractContractTokenHeuristic(text: string): string | undefined { @@ -610,207 +604,20 @@ function extractContractTokenHeuristic(text: string): string | undefined { function isLikelyCounterpartyToken(rawToken: string): boolean { const token = String(rawToken ?? "").trim(); - const lowered = token.toLowerCase(); - if (!token || token.length < 2) { + if (!token || token.length < 3) { return false; } + const lowered = token.toLowerCase(); if (/^\d+$/.test(lowered)) { return false; } if (/^(?:19|20)\d{2}$/.test(lowered)) { return false; } - - const stopWords = new Set([ - "за", - "с", - "по", - "у", - "на", - "и", - "или", - "какие", - "какой", - "какая", - "какое", - "каких", - "каким", - "какими", - "каком", - "какому", - "какую", - "кто", - "что", - "чего", - "где", - "когда", - "почему", - "зачем", - "сколько", - "чьи", - "чья", - "чей", - "чью", - "мы", - "видим", - "самый", - "самая", - "самое", - "самые", - "крупный", - "крупная", - "крупное", - "крупные", - "жирный", - "жирная", - "жирное", - "жирные", - "больше", - "меньше", - "платит", - "платят", - "прогноз", - "forecast", - "план", - "плана", - "ндс", - "vat", - "налог", - "оплата", - "оплаты", - "платеж", - "платёж", - "платежа", - "платежи", - "денег", - "деньги", - "объем", - "объём", - "док", - "доки", - "документ", - "документы", - "документов", - "документами", - "документу", - "документе", - "документа", - "документах", - "докам", - "доками", - "банк", - "банковские", - "операции", - "платежи", - "платеж", - "платёж", - "контрагент", - "контрагенту", - "контрагента", - "контрагентам", - "компания", - "компании", - "организация", - "организации", - "поставщикам", - "клиентам", - "покупателям", - "заказчикам", - "аванс", - "авансы", - "проблемный", - "проблемные", - "проблемным", - "закрытия", - "закрыть", - "закрыты", - "год", - "года", - "г", - "плс", - "pls", - "пж", - "пжлст", - "пожалуйста", - "бля", - "блять", - "епт", - "ёпт", - "епта", - "нах", - "нахуй", - "есть", - "же", - "сводные", - "сводный", - "сводная", - "сводную", - "сводном", - "сводного", - "сводному", - "неуказанному", - "неуказанный", - "неуказанная", - "неуказанное", - "указанному", - "указанный", - "указанная", - "указанное", - "объекту", - "объект", - "покеж", - "покажи", - "скажи", - "показать", - "выведи", - "show", - "list", - "please", - "теперь", - "сейчас", - "вернись", - "вернуться", - "вернуть", - "раскрой", - "раскрыть", - "раскройте", - "нему", - "ней", - "ним", - "этому", - "тому", - "этомуже", - "томуже", - "vse", - "all", - "kakie", - "kakoi", - "est", - "za", - "po", - "na", - "s", - "poka", - "pokaji", - "skazhi", - "pokazhi", - "pokazh", - "pokezh", - "doki", - "doky", - "dokument", - "dokumenty", - "documents", - "docs", - "связанный", - "связанные", - "связанных", - "связанным", - "связанному", - "related", - "linked" - ]); - return !stopWords.has(lowered); + if (/^(?:(?:19|20)?\d{2})(?:-?й)?(?:г|год|года)?$/iu.test(lowered)) { + return false; + } + return !isCounterpartyNoiseToken(lowered); } function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { @@ -892,64 +699,25 @@ function hasDocsOrBankSignal(text: string): boolean { ); } -function extractCounterpartyFromFreeTextHeuristic(text: string): string | undefined { - if (!hasDocsOrBankSignal(text)) { - return undefined; +function hasStrongCounterpartyTokenShape(token: string): boolean { + const source = String(token ?? "").trim(); + if (!source) { + return false; } - - const tokens = String(text ?? "") - .split(/[^\p{L}\p{N}._-]+/u) - .map((item) => item.trim()) - .filter((item) => item.length > 0); - - if (tokens.length === 0) { - return undefined; + if (/[0-9]/u.test(source) || /[._/-]/u.test(source)) { + return true; } - - const monthTokens = [ - "янв", - "фев", - "мар", - "апр", - "май", - "июн", - "июл", - "авг", - "сен", - "сент", - "окт", - "ноя", - "дек", - "january", - "february", - "march", - "april", - "may", - "june", - "july", - "august", - "september", - "october", - "november", - "december" - ]; - for (const token of tokens) { - const lowered = token.toLowerCase(); - if (!isLikelyCounterpartyToken(lowered)) { - continue; - } - if (/^\d{2}$/.test(lowered) || /^\d{4}$/.test(lowered)) { - continue; - } - if (monthTokens.some((prefix) => lowered.startsWith(prefix))) { - continue; - } - if (/(?:^за$|^for$|^from$|^to$|^по$|^с$|^год$|^года$|^г$|^year$)/iu.test(lowered)) { - continue; - } - return token; + if (/[A-ZА-ЯЁ]/u.test(source)) { + return true; } - return undefined; + // Keep only compact lowercase slang aliases (e.g. "svk"), not arbitrary words. + if (/^[a-z]{2,6}$/u.test(source)) { + return true; + } + if (/^[а-яё]+$/iu.test(source) && source.length <= 4) { + return true; + } + return false; } function extractImplicitCounterpartyValue(text: string): string | undefined { @@ -961,7 +729,7 @@ function extractImplicitCounterpartyValue(text: string): string | undefined { const beforeDocsMatch = input.match(beforeDocsPattern); if (beforeDocsMatch) { const candidate = String(beforeDocsMatch[1] ?? "").trim(); - if (isLikelyCounterpartyToken(candidate)) { + if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) { return candidate; } } @@ -973,7 +741,7 @@ function extractImplicitCounterpartyValue(text: string): string | undefined { const afterDocsMatch = input.match(afterDocsPattern); if (afterDocsMatch) { const candidate = String(afterDocsMatch[1] ?? "").trim(); - if (isLikelyCounterpartyToken(candidate)) { + if (hasStrongCounterpartyTokenShape(candidate) && isLikelyCounterpartyToken(candidate)) { return candidate; } } @@ -1022,6 +790,9 @@ function extractLeadingCounterpartyTokenHeuristic(text: string): string | undefi ]; for (const token of tokens.slice(0, 3)) { const lowered = token.toLowerCase(); + if (!hasStrongCounterpartyTokenShape(token)) { + continue; + } if (!isLikelyCounterpartyToken(lowered)) { continue; } @@ -1154,18 +925,6 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent warnings.push("counterparty_anchor_derived_from_implicit_phrase"); } } - if ( - !filters.counterparty && - (intent === "list_documents_by_counterparty" || - intent === "bank_operations_by_counterparty" || - intent === "list_contracts_by_counterparty") - ) { - const heuristicCounterparty = extractCounterpartyFromFreeTextHeuristic(text); - if (heuristicCounterparty) { - filters.counterparty = cleanupAnchorValue(heuristicCounterparty); - warnings.push("counterparty_anchor_derived_from_free_text_heuristic"); - } - } if ( !filters.counterparty && (intent === "list_documents_by_counterparty" || diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 5fb95ab..c3194d5 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -1111,6 +1111,7 @@ function buildLimitedScopeLine(filters: AddressFilterSet): string | null { function buildLimitedOffers(input: { category: AddressLimitedReasonCategory; shape: AddressQueryShapeDetection; + intent: AddressIntent; filters: AddressFilterSet; missingRequiredFilters: string[]; reason: string; @@ -1139,6 +1140,14 @@ function buildLimitedOffers(input: { } } + if (input.intent === "list_receivables_counterparties") { + offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); + } else if (input.intent === "list_payables_counterparties") { + offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76"); + } else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") { + offers.push("показать незакрытые договоры и хвосты взаиморасчетов на дату"); + } + if (counterparty) { offers.push(`показать документы и платежи по контрагенту ${counterparty}`); } else if (contract) { @@ -1170,49 +1179,128 @@ function buildLimitedOffers(input: { return Array.from(new Set(offers)).slice(0, 3); } +function buildLimitedIntentSignalLine(input: { + intent: AddressIntent; + shape: AddressQueryShapeDetection; +}): string | null { + const byIntent: Partial> = { + list_documents_by_counterparty: "Сигнал запроса: нужен срез документов/платежей по контрагенту.", + list_documents_by_contract: "Сигнал запроса: нужен срез документов/платежей по договору.", + bank_operations_by_counterparty: "Сигнал запроса: нужен срез банковских операций по контрагенту.", + bank_operations_by_contract: "Сигнал запроса: нужен срез банковских операций по договору.", + open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.", + list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", + list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", + list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов." + }; + + const byShape: Partial> = { + AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.", + DOCUMENT_LIST: "Сигнал запроса: список документов/операций.", + OBJECT_LOOKUP: "Сигнал запроса: поиск конкретных объектов.", + VERIFY_FACTUAL: "Сигнал запроса: проверка фактического состояния по данным.", + COMPOUND_FACTUAL_QUERY: "Сигнал запроса: комбинированная проверка взаимосвязанных фактов." + }; + + return byIntent[input.intent] ?? byShape[input.shape.shape] ?? null; +} + +function hasAggregateLimitedSignal(input: { + shape: AddressQueryShapeDetection; + intent: AddressIntent; + reason: string; +}): boolean { + if (input.shape.shape === "AGGREGATE_LOOKUP") { + return true; + } + if ( + input.intent === "counterparty_population_and_roles" || + input.intent === "counterparty_activity_lifecycle" || + input.intent === "contract_usage_overview" || + input.intent === "supplier_payouts_profile" || + input.intent === "customer_revenue_and_payments" || + input.intent === "contract_usage_and_value" + ) { + return true; + } + return /(?:оборот|выруч|доход|прибыл|марж|рентабел|тренд|динам|самый|топ|ranking|revenue|profit|margin|year)/iu.test( + String(input.reason ?? "") + ); +} + function composeLimitedReply(input: { category: AddressLimitedReasonCategory; reason: string; nextStep?: string; shape: AddressQueryShapeDetection; + intent: AddressIntent; filters: AddressFilterSet; missingRequiredFilters: string[]; }): string { const reason = normalizeLimitedReason(input.reason); const headingSeed = `${input.category}|${input.shape.shape}|${reason}`; + const aggregateLimitedSignal = hasAggregateLimitedSignal({ + shape: input.shape, + intent: input.intent, + reason: input.reason + }); const heading = input.category === "empty_match" ? pickDeterministicVariant(headingSeed, [ "По текущим условиям в доступном срезе данных совпадений не нашлось.", - "В текущем срезе данных по этому запросу совпадения не найдены." + "В текущем срезе данных по этому запросу совпадения не найдены.", + "По заданным фильтрам в текущем срезе совпадений пока нет." ]) : input.category === "missing_anchor" ? pickDeterministicVariant(headingSeed, [ "Чтобы ответ был точным, нужно чуть сильнее заякорить запрос.", - "Запрос понятен, но для надежного ответа не хватает опорного ориентира." + "Запрос понятен, но для надежного ответа не хватает опорного ориентира.", + "Вопрос по смыслу ясен, но пока не хватает конкретной опоры для выборки." ]) : input.category === "recipe_visibility_gap" ? pickDeterministicVariant(headingSeed, [ "Запрос понятен, но текущий сценарий выборки не дает нужной детализации.", - "Смысл запроса ясен, но в этом контуре не хватает глубины выборки." + "Смысл запроса ясен, но в этом контуре не хватает глубины выборки.", + "Сценарий запроса корректный, но текущая витрина не дает нужной детализации." ]) : input.category === "unsupported" ? pickDeterministicVariant(headingSeed, [ - "По этому вопросу в текущем адресном контуре пока нет надежного маршрута ответа.", - "Сейчас в адресном режиме такой сценарий не закрыт без риска ошибочного вывода." + "Сейчас не дам прямой адресный ответ, чтобы не ошибиться в выводах.", + "В текущем адресном контуре этот запрос лучше не закрывать «в лоб» — риск неверной трактовки высок.", + "Для такого формата запроса нужен более широкий аналитический контур, иначе ответ будет ненадежным." ]) : "Не удалось завершить проверку в адресном режиме."; + const reasonSeed = `${headingSeed}|reason`; const reasonLine = input.category === "unsupported" - ? "Коротко: сценарий пока не покрыт текущими адресными маршрутами." + ? aggregateLimitedSignal + ? pickDeterministicVariant(reasonSeed, [ + "Это агрегатный/сравнительный вопрос: без расширенного анализа здесь легко дать ложную метрику.", + "Запрос про сводную аналитику или ранжирование, поэтому в address-контуре ответ сейчас будет ненадежным.", + "Нужна расширенная аналитическая обработка: адресный режим в этом кейсе не гарантирует корректный расчет." + ]) + : pickDeterministicVariant(reasonSeed, [ + "Сценарий пока не закрыт текущими адресными маршрутами без потери точности.", + "Для этого запроса пока нет надежного ответа внутри текущего address-контура." + ]) : input.category === "missing_anchor" - ? "Коротко: не хватает конкретного ориентира (контрагент, договор, счет или период)." + ? pickDeterministicVariant(reasonSeed, [ + "Нужно чуть точнее заякорить запрос: не хватает конкретного ориентира (контрагент, договор, счет или период).", + "Для точного ответа нужен хотя бы один явный якорь: контрагент, договор, счет или период." + ]) : input.category === "recipe_visibility_gap" - ? "Коротко: для уверенного ответа нужен более специализированный сценарий выборки." - : `Коротко: ${reason}.`; + ? "Для уверенного ответа нужен более специализированный сценарий выборки." + : `${reason}.`; const lines = [heading, reasonLine]; + const signalLine = buildLimitedIntentSignalLine({ + intent: input.intent, + shape: input.shape + }); + if (signalLine && !(input.category === "unsupported" && aggregateLimitedSignal)) { + lines.push(signalLine); + } const scopeLine = buildLimitedScopeLine(input.filters); if (scopeLine) { lines.push(scopeLine); @@ -1221,6 +1309,7 @@ function composeLimitedReply(input: { const offers = buildLimitedOffers({ category: input.category, shape: input.shape, + intent: input.intent, filters: input.filters, missingRequiredFilters: input.missingRequiredFilters, reason: input.reason, @@ -1275,6 +1364,7 @@ function buildLimitedExecutionResult(input: { reason: input.reasonText, nextStep: input.nextStep, shape: input.shape, + intent: input.intent.intent, filters: input.filters, missingRequiredFilters: input.missingRequiredFilters }), diff --git a/llm_normalizer/backend/src/services/answerComposer.ts b/llm_normalizer/backend/src/services/answerComposer.ts index 239125c..490565a 100644 --- a/llm_normalizer/backend/src/services/answerComposer.ts +++ b/llm_normalizer/backend/src/services/answerComposer.ts @@ -4306,6 +4306,74 @@ function renderPolicyReply(structure: AnswerStructureV11, context?: AnswerRender ); } +function shouldUseSoftPolicyReply(input: { + mode: PolicyMode; + policySignals: PolicySignals; + limitationReasonCodes: EvidenceLimitationReasonCode[]; + aggregateEvidenceConfidence: EvidenceConfidence; + coverageReport: RequirementCoverageReport; + hasCriticalEvidenceLimitation: boolean; +}): boolean { + if (input.mode === "focused_grounded" || input.mode === "route_mismatch" || input.mode === "backend_error" || input.mode === "out_of_scope") { + return false; + } + if (input.mode === "clarification_required" || input.mode === "no_grounded" || input.mode === "empty") { + return true; + } + if (input.mode !== "broad_partial") { + return false; + } + const hasCoverageGaps = + input.coverageReport.requirements_uncovered.length > 0 || + input.coverageReport.requirements_partially_covered.length > 0 || + input.coverageReport.clarification_needed_for.length > 0 || + input.coverageReport.out_of_scope_requirements.length > 0; + const weakEvidenceSignals = + input.policySignals.broad_query_detected || + input.policySignals.broad_result_flag || + input.policySignals.minimum_evidence_failed || + input.aggregateEvidenceConfidence === "low" || + input.hasCriticalEvidenceLimitation || + input.limitationReasonCodes.includes("weak_source_mapping") || + input.limitationReasonCodes.includes("insufficient_detail") || + input.limitationReasonCodes.includes("missing_mechanism"); + return hasCoverageGaps || weakEvidenceSignals; +} + +function renderSoftPolicyReply(input: { + structure: AnswerStructureV11; + context?: AnswerRenderContext; + mode: PolicyMode; +}): string { + const questionType = input.context?.questionType ?? "unknown"; + const shortLine = ensureSentence(buildShortSectionLine(input.structure)); + const evidenceLines = dedupeNarrativeLines(buildEvidenceSectionLines(input.structure, questionType, input.context), 3); + const limitationLines = dedupeNarrativeLines(buildLimitationsSectionLines(input.structure), 3); + const checkLines = dedupeNarrativeLines(buildChecksSectionLines(input.structure, input.context), 3); + const clarificationLines = dedupeNarrativeLines(input.structure.next_step_block.clarification_questions ?? [], 2); + const actionLines = dedupeNarrativeLines( + [...checkLines, ...(input.structure.next_step_block.recommended_actions ?? []), ...clarificationLines], + 3 + ); + const modeLine = + input.mode === "clarification_required" + ? "Чтобы дать точный ответ, нужно уточнить несколько ориентиров." + : input.mode === "no_grounded" || input.mode === "empty" + ? "Сейчас подтвержденной опоры недостаточно для прямого вывода." + : "Есть рабочие сигналы, но часть вывода пока ограничена."; + return sanitizeUserFacingReply( + [ + `Коротко: ${shortLine}`, + modeLine, + evidenceLines.length > 0 ? `Что уже проверено: ${evidenceLines.join("; ")}` : "", + limitationLines.length > 0 ? `Что пока не доказано: ${limitationLines.join("; ")}` : "", + actionLines.length > 0 ? `Что могу сделать сейчас: ${actionLines.join("; ")}` : "" + ] + .filter(Boolean) + .join("\n\n") + ); +} + function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutput { const fallbackType = fallbackFromSummary(input.routeSummary); const questionType: QuestionTypeClass = input.questionTypeHint ?? "unknown"; @@ -4596,12 +4664,30 @@ function composeAssistantAnswerV11(input: ComposeAnswerInput): ComposeAnswerOutp missingAnchors, coverageReport: input.coverageReport }) - : renderPolicyReply(answerStructure, { - questionType, - focusDomain: focusNarrativeDomain, - anchors: anchorUsage, - userMessage: input.userMessage - }); + : shouldUseSoftPolicyReply({ + mode: guardedDecision.mode, + policySignals, + limitationReasonCodes, + aggregateEvidenceConfidence, + coverageReport: input.coverageReport, + hasCriticalEvidenceLimitation + }) + ? renderSoftPolicyReply({ + structure: answerStructure, + context: { + questionType, + focusDomain: focusNarrativeDomain, + anchors: anchorUsage, + userMessage: input.userMessage + }, + mode: guardedDecision.mode + }) + : renderPolicyReply(answerStructure, { + questionType, + focusDomain: focusNarrativeDomain, + anchors: anchorUsage, + userMessage: input.userMessage + }); return { assistant_reply: finalAssistantReply, diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 0d0c897..a3532b2 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -998,6 +998,27 @@ function countTokens(text) { function hasPeriodLiteral(text) { return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text); } +function hasStandaloneAddressTopicSignal(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + if (hasFollowupMarker(normalized) || hasReferentialPointer(normalized)) { + return false; + } + const hasRequestCue = /(?:^|[\s,.;:!?()\-])(?:покажи|показать|выведи|дай|найди|список|какие|какой|какая|каких|сколько|где|show|list|find|which|what)/iu.test(normalized); + if (!hasRequestCue) { + return false; + } + const hasBusinessObject = /(?:договор|контракт|контрагент|поставщик|покупател|клиент|документ|платеж|оплат|сальдо|остатк|сч[её]т|оборот|выруч|доход|прибыл|ндс|дебитор|кредитор|организац|компан|контор|contract|counterparty|supplier|customer|document|payment|turnover|revenue|profit|balance|account|vat)/iu.test(normalized); + if (!hasBusinessObject) { + return false; + } + const hasStructuredAnchor = hasPeriodLiteral(normalized) || + /\b\d{2}(?:[.,]\d{1,2})?\b/.test(normalized) || + /(?:альтернатива|лайсвуд|райм|ооо\s+[a-zа-яё])/iu.test(normalized); + return hasStructuredAnchor || countTokens(normalized) >= 6; +} function extractNormalizedPeriodLiteral(text) { const monthly = text.match(/\b(20\d{2})[-/.](0[1-9]|1[0-2])\b/); if (monthly) { @@ -1304,6 +1325,35 @@ function buildAddressCoverageReport() { out_of_scope_requirements: [] }; } +function buildAssistantBackendErrorDebugPayload(errorMessage) { + return { + trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`, + prompt_version: "assistant_backend_error_fallback_v1", + schema_version: "assistant_backend_error_fallback_v1", + fallback_type: "unknown", + route_summary: null, + fragments: [], + requirements_extracted: [], + coverage_report: buildAddressCoverageReport(), + routes: [], + retrieval_status: [], + retrieval_results: [], + answer_grounding_check: { + status: "no_grounded_answer", + route_subject_match: true, + missing_requirements: [], + reasons: [ + `backend_error:${String(errorMessage ?? "unknown_error").slice(0, 280)}` + ], + why_included_summary: [], + selection_reason_summary: [] + }, + dropped_intent_segments: [] + }; +} +function buildAssistantBackendErrorReply() { + return "Сейчас не удалось завершить разбор из-за внутренней ошибки контуров LLM. Могу продолжить в адресном режиме: проверить документы, договоры и операции по нужному периоду или контрагенту."; +} function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) { const grounded = addressDebug.response_type === "LIMITED_WITH_REASON" ? "partial" : "grounded"; const llmMeta = llmPreDecomposeMeta && typeof llmPreDecomposeMeta === "object" ? llmPreDecomposeMeta : null; @@ -2170,6 +2220,9 @@ function hasAddressFollowupContextSignal(userMessage) { if (!text) { return false; } + if (hasStandaloneAddressTopicSignal(text)) { + return false; + } if (shouldHandleAsAssistantCapabilityMetaQuery(text)) { return false; } @@ -2228,6 +2281,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) ? hasAddressFollowupContextSignal(alternateMessage) : false; + const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || + (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); + if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) { + return null; + } if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) { return null; } @@ -2859,6 +2917,29 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; + const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety && + sourceAnchorQuality.anchorType === "counterparty" && + sourceAnchorQuality.quality >= 2 && + Boolean(sourceAnchorQuality.anchorValue) && + ((candidateAnchorQuality.anchorType === "counterparty" && + candidateAnchorQuality.quality >= 2 && + Boolean(candidateAnchorQuality.anchorValue) && + hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? "")) || + (candidateAnchorQuality.quality < sourceAnchorQuality.quality && + hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidate))); + if (counterpartyAnchorSubstitutedByCandidate) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_anchor_substitution", + fallbackRuleHit: null, + sanitizedUserMessage + }, userMessage); + } const anchorDegradedByCandidate = sameIntentForAnchorSafety && sourceAnchorQuality.anchorType && sourceAnchorQuality.quality >= 2 && @@ -2876,27 +2957,6 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage sanitizedUserMessage }, userMessage); } - const counterpartyAnchorSubstitutedByCandidate = sameIntentForAnchorSafety && - sourceAnchorQuality.anchorType === "counterparty" && - candidateAnchorQuality.anchorType === "counterparty" && - sourceAnchorQuality.quality >= 2 && - candidateAnchorQuality.quality >= 2 && - Boolean(sourceAnchorQuality.anchorValue) && - Boolean(candidateAnchorQuality.anchorValue) && - hasCounterpartyAnchorSubstitution(sourceAnchorQuality.anchorValue ?? "", candidateAnchorQuality.anchorValue ?? ""); - if (counterpartyAnchorSubstitutedByCandidate) { - return attachAddressPredecomposeContract({ - ...baseMeta, - attempted: true, - applied: false, - traceId: normalized?.trace_id ?? null, - llmCanonicalCandidateDetected: true, - effectiveMessage: userMessage, - reason: "normalized_fragment_rejected_anchor_substitution", - fallbackRuleHit: null, - sanitizedUserMessage - }, userMessage); - } if (fallbackCandidate) { const fallbackAnchorQuality = evaluateAddressAnchorQuality(String(fallbackCandidate.candidate ?? "")); const fallbackPreferredForAnchorSafety = sameIntentForAnchorSafety && @@ -3079,6 +3139,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll llmContractIntent === "unknown"; const hasAnyAddressSignal = hasClassifierSignal || hasLlmCanonicalSignal || hasLlmCanonicalDataSignal || hasLexicalAddressSignal; const strongDataSignalFromRawMessage = hasStrongDataIntentSignal(rawMessageForGate) || + hasDataRetrievalRequestSignal(rawMessageForGate) || hasAccountingSignal(rawMessageForGate) || hasSameDateAccountFollowupSignalForPredecompose(rawMessageForGate); const strongDataSignalFromEffectiveMessage = hasStrongDataIntentSignal(repairedInputMessage) || @@ -3105,7 +3166,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll reason: "llm_predecompose_unsupported_mode" }; } - const hasMessageSignal = hasAnyAddressSignal; + const hasMessageSignal = hasAnyAddressSignal || strongDataSignalFromRawMessage || strongDataSignalFromEffectiveMessage; if (hasMessageSignal) { return { runAddressLane: true, @@ -3190,7 +3251,9 @@ function hasDeepAnalysisPreferenceSignal(text) { } 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 diagnosticsKeywordSignal = /(?:\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|audit|scan|\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 diagnosticsCheckVerbSignal = /(?:^|[\s,.;:!?()\-])\u043f\u0440\u043e\u0432\u0435\u0440(?:\u044c|\u0438\u0442\u044c|\u043a\u0443|\u0438\u043c|\u043a\u0430)(?:$|[\s,.;:!?()\-])/iu.test(lower); + const diagnosticsSignal = diagnosticsKeywordSignal || diagnosticsCheckVerbSignal; 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); @@ -3198,13 +3261,12 @@ function hasDeepAnalysisPreferenceSignal(text) { 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 || + return lifecycleMismatchSignal || (chainSignal && lifecycleTransitionGapSignal) || expectedActualMismatchSignal || (chainSignal && diagnosticsSignal) || - (riskOrAnomalySignal && (chainSignal || closureSignal || diagnosticsSignal || closureIntentSignal)) || - (diagnosticsSignal && closureIntentSignal) || + (riskOrAnomalySignal && (chainSignal || diagnosticsSignal || lifecycleTransitionGapSignal)) || + (diagnosticsSignal && (closureSignal || closureIntentSignal)) || closureDiagnosticPhraseSignal || signalVsNoiseDiagnostic; } @@ -3213,7 +3275,7 @@ function hasDirectDeepAnalysisSignal(text) { 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); + 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\u0438[\u0435\u044f]\s+\u043f\u0435\u0440\u0438\u043e\u0434|period\s*close|close\s+period|\u0447\u0442\u043e\s+\u043c\u0435\u0448\u0430[\u0430-\u044f]+\s+\u0437\u0430\u043a\u0440\u044b\u0442|state\s+transition|root\s*cause|trace\s*chain)/iu.test(normalized); } function hasStrictDeepInvestigationCue(text) { const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); @@ -3239,6 +3301,23 @@ function hasAggregateBusinessAnalyticsSignal(text) { 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; } +function hasOpenContractsAddressSignal(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + const hasContractCue = /(?:договор|контракт|contract)/iu.test(normalized); + if (!hasContractCue) { + return false; + } + const hasOpenCue = /(?:незакрыт|не\s+закрыт|открыт|open\s+contract|open\s+item|open)/iu.test(normalized); + if (!hasOpenCue) { + return false; + } + const hasRequestCue = /(?:покажи|показать|список|какие|какой|show|list|find|на\s+дату|as\s+of)/iu.test(normalized); + const hasTemporalCue = hasPeriodLiteral(normalized) || /\b\d{4}[-/.]\d{2}[-/.]\d{2}\b/.test(normalized); + return hasRequestCue || hasTemporalCue; +} const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "list_open_contracts", "open_items_by_counterparty_or_contract", @@ -3276,10 +3355,21 @@ export function resolveAssistantOrchestrationDecision(input) { hasAggregateBusinessAnalyticsSignal(repairedRawUserMessage) || hasAggregateBusinessAnalyticsSignal(effectiveAddressUserMessage) || hasAggregateBusinessAnalyticsSignal(repairedEffectiveAddressUserMessage); + const standaloneAddressTopicSignal = hasStandaloneAddressTopicSignal(rawUserMessage) || + hasStandaloneAddressTopicSignal(repairedRawUserMessage) || + hasStandaloneAddressTopicSignal(effectiveAddressUserMessage) || + hasStandaloneAddressTopicSignal(repairedEffectiveAddressUserMessage); + const openContractsAddressSignal = hasOpenContractsAddressSignal(rawUserMessage) || + hasOpenContractsAddressSignal(repairedRawUserMessage) || + hasOpenContractsAddressSignal(effectiveAddressUserMessage) || + hasOpenContractsAddressSignal(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 llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason); + const llmRuntimeUnavailableDetected = Boolean(llmPreDecomposeReason && + /(?:openai\s+api\s+key\s+is\s+missing|api\s+key\s+is\s+missing|missing\s+api\s+key|authentication)/iu.test(llmPreDecomposeReason)); const semanticExtractionContract = llmPreDecomposeMeta?.semanticExtractionContract && typeof llmPreDecomposeMeta.semanticExtractionContract === "object" ? llmPreDecomposeMeta.semanticExtractionContract @@ -3295,7 +3385,8 @@ export function resolveAssistantOrchestrationDecision(input) { hasStrictDeepInvestigationCue(repairedEffectiveAddressUserMessage); const keepAddressLaneByIntent = semanticApplyCanonicalRecommended && Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || - (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))) && + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || + openContractsAddressSignal) && !strictDeepInvestigationCueDetected; const strongDataSignal = hasStrongDataIntentSignal(rawUserMessage) || hasStrongDataIntentSignal(repairedRawUserMessage) || @@ -3387,7 +3478,8 @@ export function resolveAssistantOrchestrationDecision(input) { hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage)); const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected && Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || - (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent))); + (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || + openContractsAddressSignal); const semanticGuardHints = semanticExtractionContract?.guard_hints && typeof semanticExtractionContract.guard_hints === "object" ? semanticExtractionContract.guard_hints @@ -3408,8 +3500,14 @@ export function resolveAssistantOrchestrationDecision(input) { const unsupportedIntentOrMode = (modeDetection.mode !== "address_query" && intentResolution.intent === "unknown") || llmContractMode === "unsupported"; const unsupportedAddressIntentFallbackToDeep = Boolean(baseToolGate?.runAddressLane && + !llmRuntimeUnavailableDetected && unsupportedIntentOrMode && strongDataSignal && + (llmContractMode === "deep_analysis" || + !dataRetrievalSignal || + strictDeepInvestigationCueDetected || + semanticDeepInvestigationHintDetected || + aggregateBusinessAnalyticsSignal) && !preserveAddressLaneSignal && !keepAddressLaneByIntent && !supportedAddressIntentDetected && @@ -3426,21 +3524,25 @@ export function resolveAssistantOrchestrationDecision(input) { 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 && + !llmRuntimeUnavailableDetected && (deepAnalysisPreferenceDetected || semanticDeepInvestigationHintDetected) && !keepAddressLaneByIntent && !supportedAddressIntentDetected && !vatExplainFollowupSignal && (!followupContext || !dataRetrievalSignal || followupSemanticOverrideToDeepAllowed)); const aggregateAnalyticsFallbackToDeep = Boolean(baseToolGate?.runAddressLane && + !llmRuntimeUnavailableDetected && aggregateBusinessAnalyticsSignal && !keepAddressLaneByIntent && !supportedAddressIntentDetected && (!followupContext || llmContractMode === "unsupported" || semanticAggregateShapeDetected || - !semanticApplyCanonicalRecommended)); + !semanticApplyCanonicalRecommended || + standaloneAddressTopicSignal)); const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && baseToolGate?.runAddressLane && + !llmRuntimeUnavailableDetected && hasDeepSessionContinuationSignal({ rawUserMessage, repairedRawUserMessage, @@ -3547,24 +3649,29 @@ export function resolveAssistantOrchestrationDecision(input) { } function hasStrongDataIntentSignal(text) { const lower = String(text ?? "").toLowerCase(); - return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|mcp|bank|counterparty|contract|document|ledger|posting|account|организац|компан|контор|фирм)/i.test(lower); + return /(база|док|документ|проводк|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|оборот|баланс|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|mcp|bank|counterparty|contract|document|ledger|posting|account|organization|company|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм)/i.test(lower); } function hasDataRetrievalRequestSignal(text) { const lower = compactWhitespace(String(text ?? "").toLowerCase()); if (!lower) { return false; } + const hasBroadInterrogative = /(?:\u0433\u0434\u0435|\u0432\s+\u043a\u0430\u043a\u0438\u0445|\u043f\u043e\s+\u043a\u0430\u043a\u0438\u043c|\u043f\u043e\s+\u043a\u043e\u043c\u0443|\u043a\u0430\u043a\u0438\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0442\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|where|which|who|how\s+many)/iu.test(lower); + const hasBroadBusinessObject = /(?:\u0430\u0432\u0430\u043d\u0441|\u043f\u0440\u0435\u0434\u043e\u043f\u043b\u0430\u0442|\u043e\u0442\u0433\u0440\u0443\u0437|\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0441\u0430\u043b\u044c\u0434\u043e|\u043e\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442(?:\u0435|\u0451)\u0436|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0441\u0447(?:\u0435|\u0451)\u0442|\u043e\u0431\u043e\u0440\u043e\u0442|\u043f\u0435\u0440\u0438\u043e\u0434|\u043c\u0435\u0441\u044f\u0446|\u0433\u043e\u0434|advance|prepay|shipment|receivab|payab|counterparty|contract|document|account|balance|turnover)/iu.test(lower); + if (hasBroadInterrogative && hasBroadBusinessObject) { + return true; + } 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); + const hasInterrogativeRetrievalAction = /(?:\bсколько\b|\bкакой\b|\bкакая\b|\bкакое\b|\bкакую\b|\bкакие\b|\bкто\b|\bгде\b|\bпо\s+каким\b|\bпо\s+кому\b|\bу\s+кого\b|\bwhich\b|\bwho\b|\bwhere\b)/i.test(lower); if (!hasExplicitRetrievalAction && !hasInterrogativeRetrievalAction) { return false; } - const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|bank|counterparty|contract|document|account|balance|ledger|posting|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); + const hasRetrievalObject = /(1с|база|док|документ|контрагент|договор|контракт|счет|сч[её]т|остат|сальдо|хвост|платеж|плат[её]ж|операц|поставщик|клиент|заказчик|дебитор|кредитор|период|месяц|год|инн|аванс|предоплат|отгруз|задолж|долг|bank|counterparty|contract|document|account|balance|ledger|posting|advance|prepay|shipment|receivab|payab|организац|компан|контор|фирм|возраст|дата\s+регистрац|регистрац|основан)/i.test(lower); if (!hasRetrievalObject) { return false; } @@ -3725,6 +3832,10 @@ function hasAssistantDataScopeMetaQuestionSignal(text) { if (!normalized) { return false; } + const hasDirectSlangScopeLead = /(?:по\s+каким\s+(?:контор(?:ам|ы|а)?|кантор(?:ам|ы|а)?|компан(?:иям|ии|ию|ия)|организац(?:иям|ии|ию|ия))\s+мож(?:ем|но)\s+(?:общат|работ)|база\s+какой\s+(?:контор|компан|организац|фирм)|какая\s+база\s+(?:подключ|подруб|актив))/iu.test(normalized); + if (hasDirectSlangScopeLead) { + return true; + } 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; @@ -4771,13 +4882,55 @@ export class AssistantService { extractExecutionState } }); - const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({ - payload, - runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)), - resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems), - runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)), - runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)) - }); - return turnRuntime.response; + try { + const turnRuntime = await (0, assistantTurnAttemptRuntimeAdapter_1.runAssistantTurnAttemptRuntime)({ + payload, + runUserTurnBootstrapRuntime: (runtimePayload) => (0, assistantUserTurnBootstrapRuntimeAdapter_1.runAssistantUserTurnBootstrapRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantUserTurnBootstrapRuntimeInput)(runtimePayload, turnRuntimeDeps)), + resolveSessionOrganizationScopeContext: (runtimeUserMessage, sessionItems) => resolveSessionOrganizationScopeContext(runtimeUserMessage, sessionItems), + runAddressAttemptRuntime: async (runtimeInput) => (0, assistantAddressAttemptRuntimeAdapter_1.runAssistantAddressAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantAddressAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)), + runDeepTurnAttemptRuntime: async (runtimeInput) => (0, assistantDeepTurnAttemptRuntimeAdapter_1.runAssistantDeepTurnAttemptRuntime)((0, assistantTurnRuntimeInputBuilder_1.buildAssistantDeepTurnAttemptRuntimeInput)(runtimeInput, turnRuntimeDeps)) + }); + return turnRuntime.response; + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const sessionId = String(payload?.session_id ?? payload?.sessionId ?? "").trim() || `asst-${(0, nanoid_1.nanoid)(10)}`; + const ensuredSession = this.sessions.ensureSession(sessionId); + const existingAssistant = [...ensuredSession.items].reverse().find((item) => item.role === "assistant") ?? null; + if (existingAssistant) { + return { + ok: true, + session_id: sessionId, + assistant_reply: existingAssistant.text, + reply_type: existingAssistant.reply_type ?? "backend_error", + conversation_item: existingAssistant, + debug: existingAssistant.debug ?? buildAssistantBackendErrorDebugPayload(errorMessage), + conversation: cloneItems(ensuredSession.items) + }; + } + const createdAt = new Date().toISOString(); + const debugPayload = buildAssistantBackendErrorDebugPayload(errorMessage); + const assistantItem = this.sessions.appendItem(sessionId, { + message_id: `msg-${(0, nanoid_1.nanoid)(10)}`, + session_id: sessionId, + role: "assistant", + text: buildAssistantBackendErrorReply(), + reply_type: "backend_error", + created_at: createdAt, + trace_id: debugPayload.trace_id ?? null, + debug: debugPayload + }); + const sessionSnapshot = this.sessions.getSession(sessionId) ?? this.sessions.ensureSession(sessionId); + this.sessionLogger.persistSession(sessionSnapshot); + return { + ok: true, + session_id: sessionId, + assistant_reply: assistantItem.text, + reply_type: "backend_error", + conversation_item: assistantItem, + debug: debugPayload, + conversation: cloneItems(sessionSnapshot.items) + }; + } } } diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 66c7739..b0dcfec 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -1883,6 +1883,15 @@ describe("address filter extraction for balance drilldown", () => { expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality"); }); + it("does not capture narrative filler as counterparty in broad docs-vs-money question", () => { + const extracted = extractAddressFilters( + "В каких случаях мы видим ситуацию, когда документы есть, а денег нет и пока не предвидится?", + "list_documents_by_counterparty" + ); + expect(extracted.extracted_filters.counterparty).toBeUndefined(); + expect(String(extracted.extracted_filters.counterparty ?? "")).not.toContain("случая"); + }); + it("does not derive fake counterparty anchor for open-contracts stale-advance wording", () => { const extracted = extractAddressFilters( "по каким поставщикам мы видим проблемные авансы, которые давно не закрыты документами?", @@ -2070,10 +2079,7 @@ describe("address filter extraction for balance drilldown", () => { it("extracts leading counterparty token for short bank phrase", () => { const result = extractAddressFilters("свк списания/поступления за 2020", "bank_operations_by_counterparty"); expect(result.extracted_filters.counterparty).toBe("свк"); - expect( - result.warnings.includes("counterparty_anchor_derived_from_leading_token") || - result.warnings.includes("counterparty_anchor_derived_from_free_text_heuristic") - ).toBe(true); + expect(result.warnings).toContain("counterparty_anchor_derived_from_leading_token"); }); it("treats 'за весь период' as all-time hint and does not force 90-day default", () => { @@ -2119,7 +2125,7 @@ describe("address filter extraction for balance drilldown", () => { expect(result.extracted_filters.period_to).toBe("2020-12-31"); }); - it("extracts free-text counterparty and relaxed short-year period from noisy phrase", () => { + it("extracts compact counterparty and relaxed short-year period from noisy phrase", () => { const result = extractAddressFilters( "свк 20 год - покажи доки плс", "list_documents_by_counterparty" @@ -2127,7 +2133,7 @@ describe("address filter extraction for balance drilldown", () => { expect(result.extracted_filters.counterparty).toBe("свк"); expect(result.extracted_filters.period_from).toBe("2020-01-01"); expect(result.extracted_filters.period_to).toBe("2020-12-31"); - expect(result.warnings).toContain("counterparty_anchor_derived_from_free_text_heuristic"); + expect(result.warnings).toContain("counterparty_anchor_derived_from_leading_token"); expect(result.warnings).toContain("period_derived_from_year_phrase"); expect(result.extracted_filters.counterparty).not.toBe("плс"); }); @@ -2172,8 +2178,8 @@ describe("address filter extraction for balance drilldown", () => { expect( result.warnings.some( (warning) => - warning === "counterparty_anchor_derived_from_free_text_heuristic" || - warning === "counterparty_anchor_derived_from_implicit_phrase" + warning === "counterparty_anchor_derived_from_implicit_phrase" || + warning === "counterparty_anchor_derived_from_leading_token" ) ).toBe(true); expect(result.warnings).toContain("period_derived_from_year_phrase"); diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 6c0b823..e4b8bc4 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -272,6 +272,28 @@ describe("assistant orchestration contract", () => { ).toBe(true); }); + it("routes standalone aggregate query to deep even when stale followup context exists and LLM predecompose is unavailable", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "какие обороты по альтернативе за 2020 год", + effectiveAddressUserMessage: "какие обороты по альтернативе за 2020 год", + followupContext: { + previous_intent: "list_documents_by_contract", + previous_filters: { + organization: "ООО Альтернатива Плюс" + } + }, + llmPreDecomposeMeta: null as any, + useMock: true + } 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("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?", @@ -289,6 +311,47 @@ describe("assistant orchestration contract", () => { expect(decision.orchestrationContract?.aggregate_analytics_signal_fallback_to_deep).toBe(true); }); + it("keeps unsupported retrieval query in address lane when LLM runtime is unavailable", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: + "\u0413\u0434\u0435 \u0443 \u043d\u0430\u0441 \u043d\u0430\u043a\u043e\u043f\u0438\u043b\u0438\u0441\u044c \u0430\u0432\u0430\u043d\u0441\u044b \u043a \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0434\u0430\u0432\u043d\u043e \u043f\u043e\u0440\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u044c?", + effectiveAddressUserMessage: + "\u0413\u0434\u0435 \u0443 \u043d\u0430\u0441 \u043d\u0430\u043a\u043e\u043f\u0438\u043b\u0438\u0441\u044c \u0430\u0432\u0430\u043d\u0441\u044b \u043a \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0434\u0430\u0432\u043d\u043e \u043f\u043e\u0440\u0430 \u0437\u0430\u043a\u0440\u044b\u0442\u044c?", + followupContext: null, + llmPreDecomposeMeta: { + applied: false, + reason: "error:OpenAI API key is missing.", + llmCanonicalCandidateDetected: false, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + }, + semanticExtractionContract: { + valid: false, + apply_canonical_recommended: false, + reason_codes: ["unsupported_low_confidence_contract", "deep_investigation_signal_detected"], + guard_hints: { + deep_investigation_signal_detected: true + }, + extraction: { + query_shape: "VERIFY_FACTUAL", + aggregation_profile: "unknown" + } + } + } as any, + useMock: false + }); + + 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.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false); + expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false); + }); + it("keeps VAT explain follow-up in address lane when followup context is present", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "почему прогноз к уплате 0?", @@ -367,6 +430,48 @@ describe("assistant orchestration contract", () => { expect(decision.toolGateReason).toBe("address_mode_classifier_detected"); }); + it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "Покажи незакрытые договоры на 2020-12-31", + effectiveAddressUserMessage: "Покажи незакрытые договоры на 2020-12-31", + followupContext: { + previous_intent: "month_close_costs_20_44" + }, + llmPreDecomposeMeta: null as any, + useMock: true + } 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"); + }); + + it("routes 'по каким конторам можем общаться?' to chat/data-scope in orchestration contract", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "по каким конторам можем общаться?", + effectiveAddressUserMessage: "по каким контрагентам у нас есть активность или договоры?", + followupContext: null, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "address_query", + mode_confidence: "medium", + intent: "list_documents_by_contract", + intent_confidence: "medium" + } + } as any, + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("assistant_data_scope_query_detected"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("assistant_data_scope_query_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/assistantSoftPolicyReply.test.ts b/llm_normalizer/backend/tests/assistantSoftPolicyReply.test.ts new file mode 100644 index 0000000..4bf707a --- /dev/null +++ b/llm_normalizer/backend/tests/assistantSoftPolicyReply.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { composeAssistantAnswer } from "../src/services/answerComposer"; +import type { UnifiedRetrievalResult } from "../src/types/assistant"; + +function routeSummary() { + 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: "none" as const, + message: null + } + }; +} + +describe("assistant soft policy reply", () => { + it("renders soft non-template reply for weak broad-partial envelope", () => { + const retrieval: UnifiedRetrievalResult = { + fragment_id: "F1", + requirement_ids: ["R1"], + route: "store_feature_risk", + status: "partial", + result_type: "summary", + items: [{ note: "weak candidate" }], + summary: { + broad_query_detected: true, + broad_result_flag: true, + minimum_evidence_failed: true, + narrowing_strength: "weak" + }, + evidence: [], + why_included: [], + selection_reason: [], + risk_factors: [], + business_interpretation: [], + confidence: "low", + limitations: ["Weak evidence envelope"], + errors: [] + }; + + const output = composeAssistantAnswer({ + userMessage: "Покажи общую картину рисков и аномалий по документам.", + routeSummary: routeSummary(), + retrievalResults: [retrieval], + requirements: [ + { + requirement_id: "R1", + source_fragment_id: "F1", + requirement_text: "общая картина рисков", + subject_tokens: [], + status: "partially_covered", + route: "store_feature_risk" + } + ], + coverageReport: { + requirements_total: 1, + requirements_covered: 0, + requirements_uncovered: [], + requirements_partially_covered: ["R1"], + clarification_needed_for: [], + out_of_scope_requirements: [] + }, + groundingCheck: { + status: "partial", + route_subject_match: true, + missing_requirements: ["R1"], + reasons: ["insufficient_detail"], + why_included_summary: [], + selection_reason_summary: [] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("partial_coverage"); + expect(output.assistant_reply).toContain("Что могу сделать сейчас:"); + expect(output.assistant_reply).toContain("Что пока не доказано:"); + expect(output.assistant_reply).not.toContain("Что сломано:"); + }); + + it("keeps structured sectioned reply for focused grounded envelope", () => { + const retrieval: UnifiedRetrievalResult = { + fragment_id: "F1", + requirement_ids: ["R1"], + route: "store_feature_risk", + status: "ok", + result_type: "summary", + items: [{ doc: "A-1" }], + summary: { + broad_query_detected: false, + broad_result_flag: false, + minimum_evidence_failed: false, + narrowing_strength: "strong" + }, + evidence: [ + { + evidence_id: "ev-1", + claim_ref: "requirement:R1", + source_type: "retrieval_item", + source_ref: { + schema_version: "evidence_source_ref_v1", + namespace: "snapshot_2020", + entity: "document", + id: "A-1", + period: "2020-06", + canonical_ref: "evidence_source_ref_v1|snapshot_2020|document|A-1|2020-06" + }, + pointer: { + fragment_id: "F1", + route: "store_feature_risk", + source: { + namespace: "snapshot_2020", + entity: "document", + id: "A-1", + period: "2020-06" + } + }, + evidence_kind: "fact", + mechanism_note: "Переход подтвержден документом и проводкой.", + confidence: "high", + payload: { + amount: 100 + } + } + ], + why_included: ["релевантная запись"], + selection_reason: ["сильное совпадение"], + risk_factors: [], + business_interpretation: [], + confidence: "high", + limitations: [], + errors: [] + }; + + const output = composeAssistantAnswer({ + userMessage: "Проверь документ A-1 за июнь 2020.", + routeSummary: routeSummary(), + retrievalResults: [retrieval], + requirements: [ + { + requirement_id: "R1", + source_fragment_id: "F1", + requirement_text: "проверить документ", + subject_tokens: [], + status: "covered", + route: "store_feature_risk" + } + ], + coverageReport: { + requirements_total: 1, + requirements_covered: 1, + requirements_uncovered: [], + requirements_partially_covered: [], + clarification_needed_for: [], + out_of_scope_requirements: [] + }, + groundingCheck: { + status: "grounded", + route_subject_match: true, + missing_requirements: [], + reasons: [], + why_included_summary: ["релевантная запись"], + selection_reason_summary: ["сильное совпадение"] + }, + enableAnswerPolicyV11: true + }); + + expect(output.reply_type).toBe("factual_with_explanation"); + expect(output.assistant_reply).toContain("Что сломано:"); + expect(output.assistant_reply).toContain("Ограничения:"); + }); +}); \ No newline at end of file diff --git a/llm_normalizer/backend/tests/evalAsyncJobSync.test.ts b/llm_normalizer/backend/tests/evalAsyncJobSync.test.ts new file mode 100644 index 0000000..ea9f46b --- /dev/null +++ b/llm_normalizer/backend/tests/evalAsyncJobSync.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { __evalRouteAsyncTestUtils } from "../src/routes/eval"; + +describe("eval async job sync", () => { + it("marks cases as completed when report already contains case results", () => { + const job = { + job_id: "job-sync-report", + status: "running", + created_at: "2026-04-11T00:00:00.000Z", + updated_at: "2026-04-11T00:00:00.000Z", + eval_target: "assistant_stage1", + run_id: "assistant-stage1-ut-sync-report", + case_set_file: "dummy.json", + analysis_date: "2017-07-10", + total_cases: 2, + completed_cases: 0, + cases: [ + { + case_id: "AUTO-001", + turns_total: 1, + status: "queued", + messages: [] + }, + { + case_id: "AUTO-002", + turns_total: 1, + status: "queued", + messages: [] + } + ], + error: null, + report: { + results: [{ case_id: "AUTO-001" }, { case_id: "AUTO-002" }] + } + } as any; + + __evalRouteAsyncTestUtils.syncJobWithSessions(job); + + expect(job.completed_cases).toBe(2); + expect(job.status).toBe("completed"); + expect(job.cases.map((item: any) => item.status)).toEqual(["completed", "completed"]); + }); + + it("keeps terminal case state when job is already completed and session file is missing", () => { + const job = { + job_id: "job-sync-sticky", + status: "completed", + created_at: "2026-04-11T00:00:00.000Z", + updated_at: "2026-04-11T00:00:00.000Z", + eval_target: "assistant_stage1", + run_id: "assistant-stage1-ut-sync-sticky", + case_set_file: "dummy.json", + analysis_date: null, + total_cases: 1, + completed_cases: 1, + cases: [ + { + case_id: "AUTO-001", + turns_total: 1, + status: "completed", + messages: [] + } + ], + error: null, + report: null + } as any; + + __evalRouteAsyncTestUtils.syncJobWithSessions(job); + + expect(job.completed_cases).toBe(1); + expect(job.status).toBe("completed"); + expect(job.cases[0].status).toBe("completed"); + }); +}); diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-dpHXAu3U4-.json b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-dpHXAu3U4-.json new file mode 100644 index 0000000..22e6bd4 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-dpHXAu3U4-.json @@ -0,0 +1,190 @@ +{ + "suite_id": "assistant_autogen_runtime_job-dpHXAu3U4-", + "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/assistant_autogen_runtime_job-wH3kvBePAs.json b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-wH3kvBePAs.json new file mode 100644 index 0000000..3b07a4a --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_runtime_job-wH3kvBePAs.json @@ -0,0 +1,190 @@ +{ + "suite_id": "assistant_autogen_runtime_job-wH3kvBePAs", + "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-Mu-hfNaOlK.report.json b/llm_normalizer/data/eval_cases/eval-Mu-hfNaOlK.report.json new file mode 100644 index 0000000..8ac4189 --- /dev/null +++ b/llm_normalizer/data/eval_cases/eval-Mu-hfNaOlK.report.json @@ -0,0 +1,112 @@ +{ + "run_id": "eval-Mu-hfNaOlK", + "timestamp": "2026-04-11T15:21:02.731Z", + "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": 2 + }, + "cases_total": 2, + "metrics": { + "schema_validation_pass_rate": 100, + "scope_detection_accuracy": null, + "scope_in_scope_rate": 100, + "multi_intent_detected_rate": 0, + "clarification_required_rate": 0, + "avg_fragments_per_message": 1, + "out_of_scope_fragment_rate": 0, + "routed_fragment_rate": 100, + "no_route_fragment_rate": 0, + "route_resolution_accuracy": null, + "no_route_precision": null, + "false_no_route_rate": null, + "execution_state_consistency_rate": 100, + "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": 2, + "checks_passed": 2 + }, + "route_distribution": { + "store_feature_risk": 1, + "hybrid_store_plus_live": 1 + }, + "fallback_distribution": { + "none": 2 + }, + "results": [ + { + "case_id": "BQ-001", + "raw_question": "Проверь счет 60 за июнь 2020", + "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": "B_29wyGfEBZtW1", + "request_count_for_case": 0 + }, + { + "case_id": "BQ-002", + "raw_question": "Покажи риски по счету 97", + "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": "Tq98dzUzkLNd9e", + "request_count_for_case": 0 + } + ] +} \ No newline at end of file diff --git a/llm_normalizer/frontend/src/components/AutoRunsHistoryPanel.tsx b/llm_normalizer/frontend/src/components/AutoRunsHistoryPanel.tsx index d2d4c3d..34f4412 100644 --- a/llm_normalizer/frontend/src/components/AutoRunsHistoryPanel.tsx +++ b/llm_normalizer/frontend/src/components/AutoRunsHistoryPanel.tsx @@ -407,6 +407,15 @@ function CommentResolvedIcon({ resolved }: { resolved: boolean }) { ); } +function CopyOutlineIcon() { + return ( + + ); +} + export function AutoRunsHistoryPanel({ connection, prompts, @@ -526,6 +535,38 @@ export function AutoRunsHistoryPanel({ [onLog] ); + const copyRunIdToClipboard = useCallback( + async (event: React.SyntheticEvent, runId: string) => { + event.stopPropagation(); + event.preventDefault(); + const value = String(runId ?? "").trim(); + if (!value) { + return; + } + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + } else { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + log(`run id copied: ${value}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setErrorText(`Копирование run id: ${message}`); + log(`copy run id error: ${message}`); + } + }, + [log] + ); + function startAssistantLiveStatusTicker(): () => void { let index = 0; setAssistantLiveStatus(ASSISTANT_STAGES[0]); @@ -1819,7 +1860,25 @@ export function AutoRunsHistoryPanel({ {formatDateTime(run.run_timestamp)} {formatShortTarget(run.eval_target)} -
{run.run_id}
+
+ {run.run_id} + void copyRunIdToClipboard(event, run.run_id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + void copyRunIdToClipboard(event, run.run_id); + } + }} + title="Скопировать run id" + aria-label={`Скопировать run id ${run.run_id}`} + > + + +
режим={run.mode ?? "нет данных"} | mock={String(run.use_mock)}
diff --git a/llm_normalizer/frontend/src/styles.css b/llm_normalizer/frontend/src/styles.css index 44ad51f..a358360 100644 --- a/llm_normalizer/frontend/src/styles.css +++ b/llm_normalizer/frontend/src/styles.css @@ -891,6 +891,60 @@ button:disabled { word-break: break-word; } +.autoruns-run-id-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.autoruns-run-id-row > span { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.autoruns-copy-run-id-btn { + border: none; + background: transparent; + color: rgb(var(--rgb-text-main)); + width: 16px; + height: 16px; + min-width: 16px; + min-height: 16px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + opacity: 0.92; + cursor: pointer; +} + +.autoruns-copy-run-id-btn:hover { + color: rgb(var(--rgb-text-main)); + opacity: 1; + background: transparent; + box-shadow: none; + transform: none; +} + +.autoruns-copy-run-id-btn:focus-visible { + outline: 1px solid rgba(var(--rgb-text-main), 0.7); + outline-offset: 1px; +} + +.autoruns-copy-icon-svg { + width: 0.82rem; + height: 0.82rem; + fill: none; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; +} + .autoruns-dialog-toolbar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -946,12 +1000,17 @@ button:disabled { padding: 8px 10px; display: grid; gap: 6px; + min-width: 0; + overflow: hidden; } .autoruns-msg header, .autoruns-msg footer { display: flex; justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + min-width: 0; gap: 8px; font-size: 0.74rem; color: var(--text-muted); @@ -960,9 +1019,19 @@ button:disabled { .autoruns-msg-head-actions { display: flex; align-items: center; + justify-content: flex-end; + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; gap: 8px; } +.autoruns-msg-head-actions > span { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + .autoruns-msg-case-tag { display: inline-flex; align-items: center; @@ -979,6 +1048,14 @@ button:disabled { white-space: pre-wrap; line-height: 1.35; font-size: 0.84rem; + overflow-wrap: anywhere; + word-break: break-word; +} + +.autoruns-msg footer span { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; } .autoruns-comment-icon {