From f1ef5f9d3cf76905ab91b90022fb9afa7340bed8 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 12 Apr 2026 23:39:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=9E=D0=9C=D0=95=D0=9D=D0=AB=20-=20?= =?UTF-8?q?=D0=92=D0=9E=D0=9F=D0=A0=D0=9E=D0=A1=D0=AB=20-=20=D0=A3=D1=81?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D1=82=D1=8C=20=D0=9D=D0=94=D0=A1=20forecast:?= =?UTF-8?q?=20=D1=81=D1=83=D0=BC=D0=BC=D0=B0=20=D0=B2=20=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0,=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20MCP=20probe=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D0=B5=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressFilterExtractor.js | 34 ++ .../dist/services/addressIntentResolver.js | 30 +- .../backend/dist/services/addressMcpClient.js | 99 ++++- .../dist/services/addressQueryService.js | 293 ++++++++++++++- .../services/address_runtime/composeStage.js | 160 ++++++-- .../backend/dist/services/assistantService.js | 75 +++- .../backend/src/services/addressMcpClient.ts | 123 +++++- .../src/services/addressQueryService.ts | 354 +++++++++++++++++- .../services/address_runtime/composeStage.ts | 218 +++++++++-- .../tests/addressMcpClientEncoding.test.ts | 33 +- .../tests/addressQueryRuntimeM23.test.ts | 165 +++++++- 11 files changed, 1483 insertions(+), 101 deletions(-) diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 985c8f4..c0ff61c 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -574,6 +574,11 @@ function isLowQualityCounterpartyAnchorValue(rawValue) { if (questionCue && (rankingCue || paymentCue)) { return true; } + const moneyAsOfPhraseCue = /(?:денег|деньг|money|cash)/iu.test(value) && + /(?:на\s+(?:данн(?:ую|ой|ая|ое)|эту|ту)\s+дат|on\s+(?:this|that)\s+date|as\s+of\s+(?:this|that)\s+date)/iu.test(value); + if (moneyAsOfPhraseCue) { + return true; + } const hasTemporalCue = /(?:по\s+состоянию|на\s+дат|на\s+конец|за\s+(?:период|месяц|год|квартал)|\b(?:19|20)\d{2}\b|\bянвар|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(value); const hasGenericEntityCue = /(?:компан|организац|контрагент|поставщик|клиент|покупател|дебитор|кредитор|counterparty|company|supplier|customer)/iu.test(value); if (hasTemporalCue && hasGenericEntityCue) { @@ -605,12 +610,41 @@ function isLowQualityCounterpartyAnchorValue(rawValue) { "ноябрь", "декабрь" ]); + const lowQualityGenericTokens = new Set([ + "деньги", + "денег", + "деньгам", + "деньгами", + "денежный", + "денежные", + "данную", + "данной", + "данный", + "данное", + "эту", + "этой", + "этот", + "этом", + "ту", + "той", + "тот", + "том", + "вцелом", + "целом" + ]); const meaningfulNonTemporalTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) && !lowQualityTimeTokens.has(token) && !/^(?:19|20)\d{2}$/.test(token)); if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) { return true; } + const meaningfulNonGenericTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) && + !lowQualityTimeTokens.has(token) && + !lowQualityGenericTokens.has(token) && + !/^(?:19|20)\d{2}$/.test(token)); + if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) { + return true; + } const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); return meaningfulTokens.length === 0; } diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 31a5c30..de28e27 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -385,6 +385,22 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [ function hasAny(text, patterns) { return patterns.some((item) => text.includes(item)); } +function hasFlexibleReceivablesDebtSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + return (/(?:кто(?:\s+\S+){0,4}\s+нам(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || + /(?:нам(?:\s+\S+){0,4}\s+кто(?:\s+\S+){0,4}\s+долж)/iu.test(normalized)); +} +function hasFlexiblePayablesDebtSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + return (/(?:кому(?:\s+\S+){0,4}\s+мы(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) || + /(?:мы(?:\s+\S+){0,4}\s+кому(?:\s+\S+){0,4}\s+долж)/iu.test(normalized)); +} function tokenizeText(text) { return String(text ?? "") .toLowerCase() @@ -1275,11 +1291,14 @@ function resolveAddressIntent(userMessage) { reasons: ["vat_payable_confirmed_signal_detected"] }; } - if (hasAny(text, RECEIVABLES_STRONG)) { - const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text); + if (hasAny(text, RECEIVABLES_STRONG) || hasFlexibleReceivablesDebtSignal(text)) { + const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text) || hasFlexibleReceivablesDebtSignal(text); const reasons = ["receivables_signal_detected"]; if (receivablesDebtLifecycleSignal) { reasons.push("receivables_debt_lifecycle_signal_detected"); + if (hasFlexibleReceivablesDebtSignal(text)) { + reasons.push("receivables_signal_detected_flexible_phrase"); + } } return { intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties", @@ -1287,11 +1306,14 @@ function resolveAddressIntent(userMessage) { reasons }; } - if (hasAny(text, PAYABLES_STRONG)) { + if (hasAny(text, PAYABLES_STRONG) || hasFlexiblePayablesDebtSignal(text)) { const reasons = ["payables_signal_detected"]; - const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text); + const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text) || hasFlexiblePayablesDebtSignal(text); if (payablesDebtLifecycleSignal) { reasons.push("payables_debt_lifecycle_signal_detected"); + if (hasFlexiblePayablesDebtSignal(text)) { + reasons.push("payables_signal_detected_flexible_phrase"); + } } return { intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties", diff --git a/llm_normalizer/backend/dist/services/addressMcpClient.js b/llm_normalizer/backend/dist/services/addressMcpClient.js index 78e0ab2..241efff 100644 --- a/llm_normalizer/backend/dist/services/addressMcpClient.js +++ b/llm_normalizer/backend/dist/services/addressMcpClient.js @@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); exports.executeAddressMcpQuery = executeAddressMcpQuery; +exports.executeAddressMcpMetadata = executeAddressMcpMetadata; const config_1 = require("../config"); const iconv_lite_1 = __importDefault(require("iconv-lite")); function toStringValue(value) { @@ -165,7 +166,7 @@ function parseRowsFromTextTable(source) { } return normalizeMojibakeRows(rows); } -function parseExecutePayload(payload) { +function parseRowsPayload(payload, options = {}) { if (!payload || typeof payload !== "object") { return { ok: false, @@ -208,6 +209,13 @@ function parseExecutePayload(payload) { error: null }; } + if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) { + return { + ok: true, + rows: [normalizeMojibakeValue(source.data)], + error: null + }; + } return { ok: true, rows: [], @@ -261,7 +269,7 @@ async function executeAddressMcpQuery(input) { }; } const payload = responseText.trim() ? JSON.parse(responseText) : {}; - const parsed = parseExecutePayload(payload); + const parsed = parseRowsPayload(payload); if (!parsed.ok) { return { fetched_rows: 0, @@ -294,3 +302,90 @@ async function executeAddressMcpQuery(input) { clearTimeout(timeout); } } +async function executeAddressMcpMetadata(input) { + const endpoint = buildMcpUrl("/api/get_metadata"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS)); + try { + const body = {}; + if (typeof input.filter === "string" && input.filter.trim().length > 0) { + body.filter = input.filter.trim(); + } + if (typeof input.meta_type === "string" && input.meta_type.trim().length > 0) { + body.meta_type = input.meta_type.trim(); + } + else if (Array.isArray(input.meta_type) && input.meta_type.length > 0) { + const values = input.meta_type + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0); + if (values.length > 0) { + body.meta_type = values; + } + } + if (typeof input.name_mask === "string" && input.name_mask.trim().length > 0) { + body.name_mask = input.name_mask.trim(); + } + if (typeof input.limit === "number" && Number.isFinite(input.limit)) { + body.limit = Math.max(1, Math.min(1000, Math.trunc(input.limit))); + } + if (typeof input.offset === "number" && Number.isFinite(input.offset)) { + body.offset = Math.max(0, Math.min(1_000_000, Math.trunc(input.offset))); + } + if (Array.isArray(input.sections) && input.sections.length > 0) { + const sections = input.sections + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0); + if (sections.length > 0) { + body.sections = sections; + } + } + if (input.extension_name !== undefined) { + body.extension_name = input.extension_name; + } + const response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json; charset=utf-8" + }, + body: JSON.stringify(body), + signal: controller.signal + }); + const responseText = await response.text(); + if (!response.ok) { + return { + fetched_rows: 0, + raw_rows: [], + rows: [], + error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}` + }; + } + const payload = responseText.trim() ? JSON.parse(responseText) : {}; + const parsed = parseRowsPayload(payload, { allowSingleObjectRow: true }); + if (!parsed.ok) { + return { + fetched_rows: 0, + raw_rows: [], + rows: [], + error: parsed.error + }; + } + return { + fetched_rows: parsed.rows.length, + raw_rows: parsed.rows, + rows: parsed.rows, + error: null + }; + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + fetched_rows: 0, + raw_rows: [], + rows: [], + error: `MCP fetch failed: ${message}` + }; + } + finally { + clearTimeout(timeout); + } +} diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index edae49b..f09628c 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -15,6 +15,10 @@ const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000; const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; +const VAT_METADATA_PROBE_LIMIT = 100; +const VAT_SOURCE_PROBE_MAX_OBJECTS = 8; +const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "РегистрСведений", "Документ"]; +const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур", "вычет", "восстанов"]; const PARTY_ANCHOR_STOPWORDS = new Set([ "ооо", "ао", @@ -122,6 +126,262 @@ function valueAsString(value) { } return String(value); } +function normalizeIsoDateForQuery(value) { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + const candidate = new Date(Date.UTC(year, month - 1, day)); + if (candidate.getUTCFullYear() !== year || + candidate.getUTCMonth() + 1 !== month || + candidate.getUTCDate() !== day) { + return null; + } + return `${match[1]}-${match[2]}-${match[3]}`; +} +function toDateTimeExprForQuery(isoDate) { + const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, 23, 59, 59)`; +} +function shouldProbeVatSourcesForForecast(userMessage) { + const text = String(userMessage ?? "") + .toLowerCase() + .replace(/ё/g, "е"); + if (!text.trim()) { + return false; + } + return /(?:в\s+налогов|почему|из\s+чего|источн|декларац|книга\s+продаж|книга\s+покупок|вычет|восстанов)/iu.test(text); +} +function detectVatMetadataObjectType(fullName) { + const normalized = String(fullName ?? "").trim(); + if (!normalized) { + return null; + } + if (normalized.startsWith("Документ.")) { + return "document"; + } + if (normalized.startsWith("РегистрНакопления.") || normalized.startsWith("РегистрСведений.")) { + return "register"; + } + return null; +} +function extractVatMetadataObjects(rows) { + const out = []; + const seen = new Set(); + for (const row of rows) { + const fullName = valueAsString(row.ПолноеИмя ?? row.full_name ?? row.FullName ?? row.Имя ?? row.name ?? row.Name).trim() || null; + if (!fullName) { + continue; + } + const objectType = detectVatMetadataObjectType(fullName); + if (!objectType) { + continue; + } + if (seen.has(fullName)) { + continue; + } + seen.add(fullName); + const synonym = valueAsString(row.Синоним ?? row.synonym ?? row.Synonym ?? row.Представление ?? row.presentation).trim() || null; + out.push({ + fullName, + synonym, + objectType + }); + } + return out; +} +function scoreVatMetadataObject(item) { + const fullName = item.fullName.toLowerCase(); + const synonym = String(item.synonym ?? "").toLowerCase(); + let score = item.objectType === "register" ? 120 : 80; + if (fullName.includes("книгипродаж") || synonym.includes("продаж")) { + score += 60; + } + if (fullName.includes("книгипокупок") || synonym.includes("покуп")) { + score += 60; + } + if (fullName.includes("начислен") || synonym.includes("начислен")) { + score += 40; + } + if (fullName.includes("предъявлен") || synonym.includes("предъявлен")) { + score += 40; + } + if (fullName.includes("оплатындс") || synonym.includes("в бюджет")) { + score += 35; + } + if (fullName.includes("декларац")) { + score -= 25; + } + if (fullName.includes("пояснен")) { + score -= 25; + } + return score; +} +function buildVatObjectProbeQuery(object, asOfExpr) { + if (object.objectType === "document") { + return ` +ВЫБРАТЬ ПЕРВЫЕ 1 + Док.Дата КАК Период, + ПРЕДСТАВЛЕНИЕ(Док.Ссылка) КАК Регистратор, + "" КАК СчетДт, + "" КАК СчетКт, + 0 КАК Сумма +ИЗ + ${object.fullName} КАК Док +ГДЕ + Док.Дата <= ${asOfExpr} +УПОРЯДОЧИТЬ ПО + Док.Дата УБЫВ +`.trim(); + } + return ` +ВЫБРАТЬ ПЕРВЫЕ 1 + Движения.Период КАК Период, + ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор, + "" КАК СчетДт, + "" КАК СчетКт, + 0 КАК Сумма +ИЗ + ${object.fullName} КАК Движения +ГДЕ + Движения.Период <= ${asOfExpr} +УПОРЯДОЧИТЬ ПО + Движения.Период УБЫВ +`.trim(); +} +async function probeVatDirectSources(filters) { + const asOfDate = normalizeIsoDateForQuery(filters.as_of_date) ?? + normalizeIsoDateForQuery(filters.period_to) ?? + normalizeIsoDateForQuery(filters.period_from); + if (!asOfDate) { + return { + status: "skipped", + objectsTotal: 0, + documentsTotal: 0, + registersTotal: 0, + probedSources: [], + errors: ["as_of_date_not_resolved_for_vat_probe"] + }; + } + const asOfExpr = toDateTimeExprForQuery(asOfDate); + if (!asOfExpr) { + return { + status: "skipped", + objectsTotal: 0, + documentsTotal: 0, + registersTotal: 0, + probedSources: [], + errors: ["as_of_expr_not_resolved_for_vat_probe"] + }; + } + const metadataRequests = VAT_METADATA_PROBE_TYPES.flatMap((metaType) => VAT_METADATA_PROBE_MASKS.map((nameMask) => ({ + meta_type: metaType, + name_mask: nameMask, + limit: VAT_METADATA_PROBE_LIMIT + }))); + const metadataResponses = await Promise.all(metadataRequests.map((request) => (0, addressMcpClient_1.executeAddressMcpMetadata)(request))); + const metadataErrors = []; + const metadataObjectsBuffer = []; + for (const [index, response] of metadataResponses.entries()) { + const request = metadataRequests[index]; + if (response.error) { + metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`); + continue; + } + metadataObjectsBuffer.push(...extractVatMetadataObjects(response.rows)); + } + const deduplicatedObjects = new Map(); + for (const item of metadataObjectsBuffer) { + const existing = deduplicatedObjects.get(item.fullName); + if (!existing) { + deduplicatedObjects.set(item.fullName, item); + continue; + } + if (!existing.synonym && item.synonym) { + deduplicatedObjects.set(item.fullName, { + ...existing, + synonym: item.synonym + }); + } + } + const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()).sort((a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru")); + const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); + const probeRows = []; + for (const object of metadataObjects) { + const probeQuery = buildVatObjectProbeQuery(object, asOfExpr); + const probeResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({ + query: probeQuery, + limit: 1 + }); + if (probeResult.error) { + probeRows.push({ + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: "error", + rowsFetched: probeResult.fetched_rows, + error: probeResult.error + }); + continue; + } + const firstRow = probeResult.raw_rows[0] ?? null; + const lastPeriod = firstRow !== null + ? valueAsString(firstRow.Период ?? firstRow.period).trim() || + null + : null; + const sampleRegistrator = firstRow !== null + ? valueAsString(firstRow.Регистратор ?? + firstRow.registrator ?? + firstRow.Registrator).trim() || null + : null; + probeRows.push({ + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: probeResult.raw_rows.length > 0 ? "ok" : "empty", + rowsFetched: probeResult.fetched_rows, + lastPeriod, + sampleRegistrator + }); + } + const status = metadataResponses.every((item) => item.error) ? "error" : "ok"; + const allErrors = [ + ...metadataErrors, + ...probeRows + .filter((item) => item.status === "error") + .map((item) => `${item.fullName}: ${valueAsString(item.error).slice(0, 120)}`) + ]; + return { + status, + objectsTotal: discoveredMetadataObjects.length, + documentsTotal: discoveredMetadataObjects.filter((item) => item.objectType === "document").length, + registersTotal: discoveredMetadataObjects.filter((item) => item.objectType === "register").length, + probedSources: probeRows, + errors: allErrors + }; +} function transliterateCyrillicToLatin(value) { const map = { а: "a", @@ -1666,12 +1926,15 @@ class AddressQueryService { shadowRouteAudit }); } - const composeOptionsFromFilters = (filterSet) => ({ + const composeOptionsFromFilters = (filterSet, options = {}) => ({ userMessage, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, - requestedResultMode + requestedResultMode, + vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined, + emphasizeNumbers: options.emphasizeNumbers ?? undefined, + useRubCurrency: options.useRubCurrency ?? undefined }); const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters); let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters); @@ -2635,7 +2898,29 @@ class AddressQueryService { shadowRouteAudit }); } - const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters)); + const vatProbeRequired = composeIntent === "vat_payable_confirmed_as_of_date" || + (composeIntent === "vat_payable_forecast" && shouldProbeVatSourcesForForecast(userMessage)); + const vatDirectSourceProbe = vatProbeRequired ? await probeVatDirectSources(executionFilters) : null; + const shouldEmphasizeNumbers = composeIntent === "vat_payable_forecast" || + composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "payables_confirmed_as_of_date" || + composeIntent === "receivables_confirmed_as_of_date"; + const shouldUseRubCurrency = composeIntent === "vat_payable_forecast"; + const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters, { + vatDirectSourceProbe, + emphasizeNumbers: shouldEmphasizeNumbers, + useRubCurrency: shouldUseRubCurrency + })); + const vatProbeLimitations = vatProbeRequired && vatDirectSourceProbe + ? vatDirectSourceProbe.status === "error" + ? ["vat_source_probe_error"] + : vatDirectSourceProbe.status === "skipped" + ? ["vat_source_probe_skipped"] + : vatDirectSourceProbe.errors.length > 0 + ? ["vat_source_probe_partial_errors"] + : [] + : []; + const factualLimitations = [...filters.warnings, ...vatProbeLimitations]; const factualResultSemantics = mergeAddressResultSemantics(deriveAddressResultSemantics({ intent: composeIntent, selectedRecipe: effectiveRecipeId, @@ -2784,7 +3069,7 @@ class AddressQueryService { route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes, route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes, ...factualResultSemantics, - limitations: filters.warnings, + limitations: factualLimitations, reasons: withConfirmedBalanceFallbackReason(reasonsWithRouteExpectation, requestedResultMode, factual.semantics, factualResultSemantics.result_mode) } }; diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index b9d49eb..a0b4d59 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -101,8 +101,35 @@ function formatNumberWithDots(value, fractionDigits = 0) { function formatMoneyRub(value) { return `${formatNumberWithDots(value, 2)} ₽`; } +function formatVatProbeStatusRu(status) { + if (status === "ok") { + return "есть движения"; + } + if (status === "empty") { + return "движения не найдены"; + } + return "ошибка запроса"; +} function emphasizeNumericTokens(line) { - return line; + if (!line) { + return line; + } + const chunks = line.split(/(`[^`]*`)/g); + return chunks + .map((chunk, index) => { + if (index % 2 === 1) { + return chunk; + } + return chunk.replace(/\b-?(?:\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,]\d+)?\b/g, (match, offset, source) => { + const before = offset > 0 ? source[offset - 1] : ""; + const after = offset + match.length < source.length ? source[offset + match.length] : ""; + if (before === "*" || after === "*") { + return match; + } + return `**${match}**`; + }); + }) + .join(""); } function parseIsoDateToken(value) { const source = String(value ?? "").trim(); @@ -135,6 +162,21 @@ function buildIsoDateWithMonthShift(year, monthOneBased, day, monthShift = 0) { const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day)); return date.toISOString().slice(0, 10); } +function shiftIsoDateToNextBusinessDay(isoDate) { + const parsed = parseIsoDateToken(isoDate); + if (!parsed) { + return isoDate; + } + const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)); + for (let guard = 0; guard < 10; guard += 1) { + const dayOfWeek = date.getUTCDay(); + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + return date.toISOString().slice(0, 10); + } + date.setUTCDate(date.getUTCDate() + 1); + } + return isoDate; +} function deriveVatDeadlineCalendar(periodFrom, periodTo) { const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom); if (!reference) { @@ -147,10 +189,10 @@ function deriveVatDeadlineCalendar(periodFrom, periodTo) { const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate(); const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1); const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay); - const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1); - const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1); - const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2); - const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3); + const declarationDueDate = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1)); + const payment1 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1)); + const payment2 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2)); + const payment3 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3)); return { periodLabel: `${quarterNumber} кв. ${reference.year}`, quarterStart, @@ -270,6 +312,13 @@ function needsVatWhyExplanation(userMessage) { } return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text); } +function needsVatCalendarDetails(userMessage) { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + return /(?:срок|когда|дата\s+уплат|декларац|дол(?:я|ями)|по\s+частям|платежн(?:ый|ого)\s+график)/iu.test(text); +} function detectRankingLimit(userMessage, fallback = 20) { const text = normalizeQuestionText(userMessage); if (!text) { @@ -1147,6 +1196,8 @@ function contractCandidatesFromRows(rows) { return uniqueStrings(candidates); } function composeFactualReply(intent, rows, options = {}) { + const applyNumericEmphasis = (line) => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line); + const joinLines = (lines) => lines.map(applyNumericEmphasis).join("\n"); if (intent === "document_type_and_account_section_profile") { const rowsByMarker = new Map(); for (const row of rows) { @@ -1926,30 +1977,54 @@ function composeFactualReply(intent, rows, options = {}) { const vatActivityDetected = totalVatTurnoverAbs > 0.0000001; const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005; const explainWhyRequested = needsVatWhyExplanation(options.userMessage); + const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage); const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo); + const formatForecastMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); + const vatProbe = options.vatDirectSourceProbe ?? null; + const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; const lines = [ - "Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).", - `Строк агрегата: ${rows.length}.`, - `Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`, - `Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`, - `Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`, - `Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`, - `Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`, - `Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.` + `Собран прогноз НДС к уплате: ${formatForecastMoney(vatToPay)}.`, + `Потенциальный перенос/переплата: ${formatForecastMoney(carryoverOrOverpayment)}.`, + `Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`, + "Режим результата: предварительная оценка по проводкам 68.02*/19* (не подтвержденная сумма налога по декларации).", + "", + "База расчета:", + `- Строк агрегата: ${formatNumberWithDots(rows.length)}.`, + `- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`, + `- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`, + `- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`, + `- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.` ]; + if (vatProbe && vatProbe.status === "ok") { + const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; + lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`); + if (vatProbe.probedSources.length > 0) { + lines.push(...vatProbe.probedSources.slice(0, 6).map((item, index) => { + const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; + return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`; + })); + } + if (vatProbe.errors.length > 0) { + lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } + lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия."); + } + else if (vatProbe && vatProbe.status === "error") { + lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*."); + } if (!vatActivityDetected) { - lines.push("В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00."); + lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`); } else if (vatToPay === 0 && netVatIsEffectivelyZero) { - lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00."); + lines.push(`В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате ${formatForecastMoney(0)}.`); } else if (vatToPay === 0 && netVat < 0) { - lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00."); + lines.push(`В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате ${formatForecastMoney(0)}.`); } if (vatToPay === 0) { - lines.push("Чеклист проверки в 1С (почему к уплате 0):", `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов."); + lines.push("", "Чеклист проверки в 1С (почему к уплате 0):", `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${periodWindowLabel ?? "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов."); } - if (vatCalendar) { + if (vatCalendar && shouldShowCalendarDetails) { const periodWindowLabel = vatCalendar.windowFrom && vatCalendar.windowTo ? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}` : `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`; @@ -1957,16 +2032,16 @@ function composeFactualReply(intent, rows, options = {}) { const installmentRaw = vatToPay / 3; const installmentRounded = Number(installmentRaw.toFixed(2)); const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2)); - lines.push(`Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, `Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."); + lines.push("", `Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, `Ориентир по долям к уплате: ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."); } if (explainWhyRequested) { - lines.push("Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", `За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`, netVat <= 0 + lines.push("", "Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", `За период 68 Кт = ${formatForecastMoney(turnover68Credit)}, 68 Дт = ${formatForecastMoney(turnover68Debit)}, разница = ${formatForecastMoney(netVat)}.`, netVat <= 0 ? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата." : "Разница положительная, поэтому к уплате берется эта положительная величина.", "Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация."); } return { responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") + text: joinLines(lines) }; } if (intent === "vat_payable_confirmed_as_of_date") { @@ -2016,14 +2091,31 @@ function composeFactualReply(intent, rows, options = {}) { "", "Блок 2. Что учтено", `- Дата среза: ${formatDateRu(asOfDate)}.`, - "- Контур: остатки по счетам НДС к уплате (68*).", - "", - "Блок 3. Сводка", - `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, - `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, - "", - "Блок 4. Подтвержденные позиции" + "- Контур: остатки по счетам НДС к уплате (68*)." ]; + const vatProbe = options.vatDirectSourceProbe ?? null; + if (vatProbe && vatProbe.status === "ok") { + const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; + lines.push("", "Блок 2.1. MCP-проверка VAT-источников", `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`); + if (vatProbe.probedSources.length > 0) { + lines.push(...vatProbe.probedSources.slice(0, 4).map((item, index) => { + const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; + const suffix = item.status === "ok" + ? `${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.sampleRegistrator ? ` | пример: ${item.sampleRegistrator}` : ""}` + : item.status === "error" && item.error + ? ` | ошибка: ${item.error}` + : ""; + return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${suffix}`; + })); + } + if (vatProbe.errors.length > 0) { + lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } + } + else if (vatProbe && vatProbe.status === "error") { + lines.push("", "Блок 2.1. MCP-проверка VAT-источников", "- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."); + } + lines.push("", "Блок 3. Сводка", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Блок 4. Подтвержденные позиции"); if (accountRows.length > 0) { lines.push(...accountRows.slice(0, 12).map((item, index) => { const refs = Array.from(item.refs).slice(0, 2).join("; "); @@ -2035,7 +2127,7 @@ function composeFactualReply(intent, rows, options = {}) { } return { responseType: "FACTUAL_LIST", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: "strong", @@ -2156,7 +2248,7 @@ function composeFactualReply(intent, rows, options = {}) { } return { responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", @@ -2223,7 +2315,7 @@ function composeFactualReply(intent, rows, options = {}) { } return { responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", @@ -2345,7 +2437,7 @@ function composeFactualReply(intent, rows, options = {}) { ]; return { responseType: "FACTUAL_LIST", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: "strong", @@ -2356,7 +2448,7 @@ function composeFactualReply(intent, rows, options = {}) { const fallbackLines = buildHeuristicLines(true); return { responseType: "FACTUAL_LIST", - text: fallbackLines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(fallbackLines), semantics: { result_mode: "heuristic_candidates", evidence_strength: counterparties.length > 0 ? "medium" : "weak", @@ -2367,7 +2459,7 @@ function composeFactualReply(intent, rows, options = {}) { const lines = buildHeuristicLines(false); return { responseType: "FACTUAL_LIST", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "heuristic_candidates", evidence_strength: counterparties.length > 0 ? "medium" : "weak", diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index b7f596e..0b70ceb 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2572,17 +2572,20 @@ function hasAddressFollowupContextSignal(userMessage) { const shortFollowup = minTokens <= 8; const ultraShortFollowup = minTokens <= 3; const debtRoleSwapToReceivables = shortFollowup && - /^(?:\u0430|\u0438)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e\b/iu.test(rawText); + (/^(?:\u0430|a|\u0438|i)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e(?=$|[\s,.;:!?])/iu.test(rawText) || + /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(rawText)); if (debtRoleSwapToReceivables) { return true; } const debtRoleSwapToPayables = shortFollowup && - /^(?:\u0430|\u0438)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443\b/iu.test(rawText); + (/^(?:\u0430|a|\u0438|i)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443(?=$|[\s,.;:!?])/iu.test(rawText) || + /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(rawText)); if (debtRoleSwapToPayables) { return true; } const shortContinuationCue = ultraShortFollowup && - /^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)\b/iu.test(rawText); + (/^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText) || + /^(?:рґр°рір°р№|рїрѕрєр°р·с‹рір°р№|рїрѕрєр°р·с‹ріс‹р°р№|рµс‰[рµс‘]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText)); if (shortContinuationCue) { return true; } @@ -2591,13 +2594,13 @@ function hasAddressFollowupContextSignal(userMessage) { if (shortVatCue) { return true; } - if (shortFollowup && hasAny(/^(?:а|и)\s+(?:нам\s+)?кто\b/iu)) { + if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) { return true; } - if (shortFollowup && hasAny(/^(?:а|и)\s+(?:мы\s+)?кому\b/iu)) { + if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu)) { return true; } - if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)\b/iu)) { + if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu)) { return true; } if (hasStandaloneAddressTopicSignal(rawText || repairedText)) { @@ -2645,13 +2648,33 @@ function hasAddressFollowupContextSignal(userMessage) { } return false; } +function hasShortDebtMirrorFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText].filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY); + if (minTokens > 8) { + return false; + } + return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) || + /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) || + /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(sample) || + /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample)); +} function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) { const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase()); if (!normalized || countTokens(normalized) > 10) { return null; } - const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized); - const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized); + const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) || + /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) || + /^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєс‚рѕ(?=$|[\s,.;:!?])/iu.test(normalized); + const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) || + /^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) || + /^(?:р°|a|рё|i)\s+(?:рјс‹\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(normalized); if ((previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties") && hasReceivablesCue) { return "receivables_confirmed_as_of_date"; @@ -3211,6 +3234,13 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) { /(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) || /\b\d{2}(?:[.,]\d{1,2})\b/u.test(source)); } +function hasPredecomposeDiagnosticUncertaintyLead(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + return /^(?:неясно|не\s+ясно|непонятно|не\s+понятно|unclear|not\s+clear|ambiguous|unknown)(?=$|[\s,.;:!?])/iu.test(normalized); +} function attachAddressPredecomposeContract(meta, sourceMessage) { const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? ""); const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ @@ -3312,6 +3342,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate); const sourceIntentKnown = sourceIntentResolution.intent !== "unknown"; const candidateIntentKnown = candidateIntentResolution.intent !== "unknown"; + const candidateStartsWithDiagnosticUncertainty = hasPredecomposeDiagnosticUncertaintyLead(candidate); + if (candidateStartsWithDiagnosticUncertainty && sourceIntentKnown) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_diagnostic_rewrite", + fallbackRuleHit: null, + sanitizedUserMessage + }, userMessage); + } const intentConflict = sourceIntentKnown && candidateIntentKnown && sourceIntentResolution.intent !== candidateIntentResolution.intent; @@ -3578,6 +3622,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll isAddressLlmPreDecomposeCandidate(repairedInputMessage) || hasAccountingSignal(addressInputMessage) || hasAccountingSignal(repairedInputMessage) || + hasShortDebtMirrorFollowupSignal(rawMessageForGate) || + hasShortDebtMirrorFollowupSignal(repairedInputMessage) || sameDateAccountFollowupSignal; const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && @@ -3595,6 +3641,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll !followupContext && !hasClassifierSignal && !hasIntentSignal && + !hasLexicalAddressSignal && !strongDataSignalFromRawMessage && !strongDataSignalFromEffectiveMessage) { return { @@ -3869,7 +3916,11 @@ function resolveAssistantOrchestrationDecision(input) { const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) || - hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage); + hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(rawUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) || + hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && !capabilityMetaQuery && @@ -3984,7 +4035,11 @@ function resolveAssistantOrchestrationDecision(input) { hasAddressFollowupContextSignal(rawUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) || - hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage)); + hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(rawUserMessage) || + hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) || + hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage)); const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected && Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || diff --git a/llm_normalizer/backend/src/services/addressMcpClient.ts b/llm_normalizer/backend/src/services/addressMcpClient.ts index ddc2eab..00f0214 100644 --- a/llm_normalizer/backend/src/services/addressMcpClient.ts +++ b/llm_normalizer/backend/src/services/addressMcpClient.ts @@ -1,4 +1,4 @@ -import { +import { ASSISTANT_MCP_CHANNEL, ASSISTANT_MCP_PROXY_URL, ASSISTANT_MCP_TIMEOUT_MS @@ -17,6 +17,13 @@ export interface AddressMcpQueryResult { error: string | null; } +export interface AddressMcpMetadataRowsResult { + fetched_rows: number; + raw_rows: Array>; + rows: Array>; + error: string | null; +} + function toStringValue(value: unknown): string { if (value === null || value === undefined) { return ""; @@ -188,7 +195,12 @@ function parseRowsFromTextTable(source: string): Array> return normalizeMojibakeRows(rows); } -function parseExecutePayload(payload: unknown): AddressMcpQueryResult { +function parseRowsPayload( + payload: unknown, + options: { + allowSingleObjectRow?: boolean; + } = {} +): AddressMcpQueryResult { if (!payload || typeof payload !== "object") { return { ok: false, @@ -240,6 +252,14 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult { }; } + if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) { + return { + ok: true, + rows: [normalizeMojibakeValue(source.data) as Record], + error: null + }; + } + return { ok: true, rows: [], @@ -312,7 +332,7 @@ export async function executeAddressMcpQuery(input: { } const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {}; - const parsed = parseExecutePayload(payload); + const parsed = parseRowsPayload(payload); if (!parsed.ok) { return { fetched_rows: 0, @@ -344,3 +364,100 @@ export async function executeAddressMcpQuery(input: { clearTimeout(timeout); } } + +export async function executeAddressMcpMetadata(input: { + filter?: string; + meta_type?: string | string[]; + name_mask?: string; + limit?: number; + offset?: number; + sections?: string[]; + extension_name?: string | null; +}): Promise { + const endpoint = buildMcpUrl("/api/get_metadata"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), Math.max(300, ASSISTANT_MCP_TIMEOUT_MS)); + try { + const body: Record = {}; + if (typeof input.filter === "string" && input.filter.trim().length > 0) { + body.filter = input.filter.trim(); + } + if (typeof input.meta_type === "string" && input.meta_type.trim().length > 0) { + body.meta_type = input.meta_type.trim(); + } else if (Array.isArray(input.meta_type) && input.meta_type.length > 0) { + const values = input.meta_type + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0); + if (values.length > 0) { + body.meta_type = values; + } + } + if (typeof input.name_mask === "string" && input.name_mask.trim().length > 0) { + body.name_mask = input.name_mask.trim(); + } + if (typeof input.limit === "number" && Number.isFinite(input.limit)) { + body.limit = Math.max(1, Math.min(1000, Math.trunc(input.limit))); + } + if (typeof input.offset === "number" && Number.isFinite(input.offset)) { + body.offset = Math.max(0, Math.min(1_000_000, Math.trunc(input.offset))); + } + if (Array.isArray(input.sections) && input.sections.length > 0) { + const sections = input.sections + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0); + if (sections.length > 0) { + body.sections = sections; + } + } + if (input.extension_name !== undefined) { + body.extension_name = input.extension_name; + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json; charset=utf-8" + }, + body: JSON.stringify(body), + signal: controller.signal + }); + + const responseText = await response.text(); + if (!response.ok) { + return { + fetched_rows: 0, + raw_rows: [], + rows: [], + error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}` + }; + } + + const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {}; + const parsed = parseRowsPayload(payload, { allowSingleObjectRow: true }); + if (!parsed.ok) { + return { + fetched_rows: 0, + raw_rows: [], + rows: [], + error: parsed.error + }; + } + + return { + fetched_rows: parsed.rows.length, + raw_rows: parsed.rows, + rows: parsed.rows, + error: null + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + fetched_rows: 0, + raw_rows: [], + rows: [], + error: `MCP fetch failed: ${message}` + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 51731c0..bcec690 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -27,10 +27,16 @@ import { selectAddressRecipe, type AddressRecipeExecutionPlan } from "./addressRecipeCatalog"; -import { executeAddressMcpQuery } from "./addressMcpClient"; +import { executeAddressMcpMetadata, executeAddressMcpQuery } from "./addressMcpClient"; import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage"; import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage"; -import { composeFactualReply, inferReplyType, type ComposeReplySemantics } from "./address_runtime/composeStage"; +import { + composeFactualReply, + inferReplyType, + type ComposeReplySemantics, + type VatDirectSourceProbeItem, + type VatDirectSourceProbeSummary +} from "./address_runtime/composeStage"; import { isCapabilityRouteBlocked, resolveAddressCapabilityRouteDecision, @@ -80,6 +86,10 @@ const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000; const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200; const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000; const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000; +const VAT_METADATA_PROBE_LIMIT = 100; +const VAT_SOURCE_PROBE_MAX_OBJECTS = 8; +const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "РегистрСведений", "Документ"] as const; +const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур", "вычет", "восстанов"] as const; const PARTY_ANCHOR_STOPWORDS = new Set([ "ооо", "ао", @@ -130,6 +140,12 @@ const ACCOUNT_ALIAS_MAP: Record = { "62": ["покупатель", "покупателями", "расчеты с покупателями"], "76": ["прочие расчеты", "прочими дебиторами и кредиторами"] }; + +interface VatMetadataObject { + fullName: string; + synonym: string | null; + objectType: "document" | "register"; +} const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, @@ -201,6 +217,293 @@ function valueAsString(value: unknown): string { return String(value); } +function normalizeIsoDateForQuery(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + const candidate = new Date(Date.UTC(year, month - 1, day)); + if ( + candidate.getUTCFullYear() !== year || + candidate.getUTCMonth() + 1 !== month || + candidate.getUTCDate() !== day + ) { + return null; + } + return `${match[1]}-${match[2]}-${match[3]}`; +} + +function toDateTimeExprForQuery(isoDate: string): string | null { + const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return null; + } + return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, 23, 59, 59)`; +} + +function shouldProbeVatSourcesForForecast(userMessage: string): boolean { + const text = String(userMessage ?? "") + .toLowerCase() + .replace(/ё/g, "е"); + if (!text.trim()) { + return false; + } + return /(?:в\s+налогов|почему|из\s+чего|источн|декларац|книга\s+продаж|книга\s+покупок|вычет|восстанов)/iu.test(text); +} + +function detectVatMetadataObjectType(fullName: string): VatMetadataObject["objectType"] | null { + const normalized = String(fullName ?? "").trim(); + if (!normalized) { + return null; + } + if (normalized.startsWith("Документ.")) { + return "document"; + } + if (normalized.startsWith("РегистрНакопления.") || normalized.startsWith("РегистрСведений.")) { + return "register"; + } + return null; +} + +function extractVatMetadataObjects(rows: Array>): VatMetadataObject[] { + const out: VatMetadataObject[] = []; + const seen = new Set(); + for (const row of rows) { + const fullName = + valueAsString(row.ПолноеИмя ?? row.full_name ?? row.FullName ?? row.Имя ?? row.name ?? row.Name).trim() || null; + if (!fullName) { + continue; + } + const objectType = detectVatMetadataObjectType(fullName); + if (!objectType) { + continue; + } + if (seen.has(fullName)) { + continue; + } + seen.add(fullName); + const synonym = + valueAsString(row.Синоним ?? row.synonym ?? row.Synonym ?? row.Представление ?? row.presentation).trim() || null; + out.push({ + fullName, + synonym, + objectType + }); + } + return out; +} + +function scoreVatMetadataObject(item: VatMetadataObject): number { + const fullName = item.fullName.toLowerCase(); + const synonym = String(item.synonym ?? "").toLowerCase(); + let score = item.objectType === "register" ? 120 : 80; + if (fullName.includes("книгипродаж") || synonym.includes("продаж")) { + score += 60; + } + if (fullName.includes("книгипокупок") || synonym.includes("покуп")) { + score += 60; + } + if (fullName.includes("начислен") || synonym.includes("начислен")) { + score += 40; + } + if (fullName.includes("предъявлен") || synonym.includes("предъявлен")) { + score += 40; + } + if (fullName.includes("оплатындс") || synonym.includes("в бюджет")) { + score += 35; + } + if (fullName.includes("декларац")) { + score -= 25; + } + if (fullName.includes("пояснен")) { + score -= 25; + } + return score; +} + +function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string): string { + if (object.objectType === "document") { + return ` +ВЫБРАТЬ ПЕРВЫЕ 1 + Док.Дата КАК Период, + ПРЕДСТАВЛЕНИЕ(Док.Ссылка) КАК Регистратор, + "" КАК СчетДт, + "" КАК СчетКт, + 0 КАК Сумма +ИЗ + ${object.fullName} КАК Док +ГДЕ + Док.Дата <= ${asOfExpr} +УПОРЯДОЧИТЬ ПО + Док.Дата УБЫВ +`.trim(); + } + return ` +ВЫБРАТЬ ПЕРВЫЕ 1 + Движения.Период КАК Период, + ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор, + "" КАК СчетДт, + "" КАК СчетКт, + 0 КАК Сумма +ИЗ + ${object.fullName} КАК Движения +ГДЕ + Движения.Период <= ${asOfExpr} +УПОРЯДОЧИТЬ ПО + Движения.Период УБЫВ +`.trim(); +} + +async function probeVatDirectSources(filters: AddressFilterSet): Promise { + const asOfDate = + normalizeIsoDateForQuery(filters.as_of_date) ?? + normalizeIsoDateForQuery(filters.period_to) ?? + normalizeIsoDateForQuery(filters.period_from); + if (!asOfDate) { + return { + status: "skipped", + objectsTotal: 0, + documentsTotal: 0, + registersTotal: 0, + probedSources: [], + errors: ["as_of_date_not_resolved_for_vat_probe"] + }; + } + + const asOfExpr = toDateTimeExprForQuery(asOfDate); + if (!asOfExpr) { + return { + status: "skipped", + objectsTotal: 0, + documentsTotal: 0, + registersTotal: 0, + probedSources: [], + errors: ["as_of_expr_not_resolved_for_vat_probe"] + }; + } + + const metadataRequests: Array<{ meta_type: string; name_mask: string; limit: number }> = VAT_METADATA_PROBE_TYPES.flatMap( + (metaType) => + VAT_METADATA_PROBE_MASKS.map((nameMask) => ({ + meta_type: metaType, + name_mask: nameMask, + limit: VAT_METADATA_PROBE_LIMIT + })) + ); + const metadataResponses = await Promise.all(metadataRequests.map((request) => executeAddressMcpMetadata(request))); + + const metadataErrors: string[] = []; + const metadataObjectsBuffer: VatMetadataObject[] = []; + for (const [index, response] of metadataResponses.entries()) { + const request = metadataRequests[index]; + if (response.error) { + metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`); + continue; + } + metadataObjectsBuffer.push(...extractVatMetadataObjects(response.rows)); + } + + const deduplicatedObjects = new Map(); + for (const item of metadataObjectsBuffer) { + const existing = deduplicatedObjects.get(item.fullName); + if (!existing) { + deduplicatedObjects.set(item.fullName, item); + continue; + } + if (!existing.synonym && item.synonym) { + deduplicatedObjects.set(item.fullName, { + ...existing, + synonym: item.synonym + }); + } + } + + const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()).sort( + (a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru") + ); + const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS); + + const probeRows: VatDirectSourceProbeItem[] = []; + for (const object of metadataObjects) { + const probeQuery = buildVatObjectProbeQuery(object, asOfExpr); + const probeResult = await executeAddressMcpQuery({ + query: probeQuery, + limit: 1 + }); + if (probeResult.error) { + probeRows.push({ + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: "error", + rowsFetched: probeResult.fetched_rows, + error: probeResult.error + }); + continue; + } + + const firstRow = probeResult.raw_rows[0] ?? null; + const lastPeriod = + firstRow !== null + ? valueAsString((firstRow as Record).Период ?? (firstRow as Record).period).trim() || + null + : null; + const sampleRegistrator = + firstRow !== null + ? valueAsString( + (firstRow as Record).Регистратор ?? + (firstRow as Record).registrator ?? + (firstRow as Record).Registrator + ).trim() || null + : null; + probeRows.push({ + fullName: object.fullName, + synonym: object.synonym, + objectType: object.objectType, + status: probeResult.raw_rows.length > 0 ? "ok" : "empty", + rowsFetched: probeResult.fetched_rows, + lastPeriod, + sampleRegistrator + }); + } + + const status: VatDirectSourceProbeSummary["status"] = metadataResponses.every((item) => item.error) ? "error" : "ok"; + const allErrors = [ + ...metadataErrors, + ...probeRows + .filter((item) => item.status === "error") + .map((item) => `${item.fullName}: ${valueAsString(item.error).slice(0, 120)}`) + ]; + + return { + status, + objectsTotal: discoveredMetadataObjects.length, + documentsTotal: discoveredMetadataObjects.filter((item) => item.objectType === "document").length, + registersTotal: discoveredMetadataObjects.filter((item) => item.objectType === "register").length, + probedSources: probeRows, + errors: allErrors + }; +} + function transliterateCyrillicToLatin(value: string): string { const map: Record = { а: "a", @@ -2096,12 +2399,22 @@ export class AddressQueryService { shadowRouteAudit }); } - const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({ + const composeOptionsFromFilters = ( + filterSet: AddressFilterSet, + options: { + vatDirectSourceProbe?: VatDirectSourceProbeSummary | null; + emphasizeNumbers?: boolean; + useRubCurrency?: boolean; + } = {} + ) => ({ userMessage, periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined, periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined, asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined, - requestedResultMode + requestedResultMode, + vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined, + emphasizeNumbers: options.emphasizeNumbers ?? undefined, + useRubCurrency: options.useRubCurrency ?? undefined }); const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters); let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters); @@ -3204,7 +3517,36 @@ export class AddressQueryService { }); } - const factual = composeFactualReply(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters)); + const vatProbeRequired = + composeIntent === "vat_payable_confirmed_as_of_date" || + (composeIntent === "vat_payable_forecast" && shouldProbeVatSourcesForForecast(userMessage)); + const vatDirectSourceProbe = vatProbeRequired ? await probeVatDirectSources(executionFilters) : null; + const shouldEmphasizeNumbers = + composeIntent === "vat_payable_forecast" || + composeIntent === "vat_payable_confirmed_as_of_date" || + composeIntent === "payables_confirmed_as_of_date" || + composeIntent === "receivables_confirmed_as_of_date"; + const shouldUseRubCurrency = composeIntent === "vat_payable_forecast"; + const factual = composeFactualReply( + composeIntent, + filteredRows, + composeOptionsFromFilters(executionFilters, { + vatDirectSourceProbe, + emphasizeNumbers: shouldEmphasizeNumbers, + useRubCurrency: shouldUseRubCurrency + }) + ); + const vatProbeLimitations = + vatProbeRequired && vatDirectSourceProbe + ? vatDirectSourceProbe.status === "error" + ? ["vat_source_probe_error"] + : vatDirectSourceProbe.status === "skipped" + ? ["vat_source_probe_skipped"] + : vatDirectSourceProbe.errors.length > 0 + ? ["vat_source_probe_partial_errors"] + : [] + : []; + const factualLimitations = [...filters.warnings, ...vatProbeLimitations]; const factualResultSemantics = mergeAddressResultSemantics( deriveAddressResultSemantics({ intent: composeIntent, @@ -3360,7 +3702,7 @@ export class AddressQueryService { route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes, route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes, ...factualResultSemantics, - limitations: filters.warnings, + limitations: factualLimitations, reasons: withConfirmedBalanceFallbackReason( reasonsWithRouteExpectation, requestedResultMode, diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index f464df2..73f3e68 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -14,12 +14,35 @@ export interface ComposeStageRow { analytics: string[]; } +export interface VatDirectSourceProbeItem { + fullName: string; + synonym?: string | null; + objectType: "document" | "register"; + status: "ok" | "empty" | "error"; + rowsFetched: number; + lastPeriod?: string | null; + sampleRegistrator?: string | null; + error?: string | null; +} + +export interface VatDirectSourceProbeSummary { + status: "ok" | "error" | "skipped"; + objectsTotal: number; + documentsTotal: number; + registersTotal: number; + probedSources: VatDirectSourceProbeItem[]; + errors: string[]; +} + interface ComposeFactualReplyOptions { userMessage?: string; periodFrom?: string; periodTo?: string; asOfDate?: string; requestedResultMode?: AddressResultMode; + vatDirectSourceProbe?: VatDirectSourceProbeSummary | null; + emphasizeNumbers?: boolean; + useRubCurrency?: boolean; } export interface ComposeReplySemantics { @@ -175,8 +198,36 @@ function formatMoneyRub(value: number): string { return `${formatNumberWithDots(value, 2)} ₽`; } +function formatVatProbeStatusRu(status: VatDirectSourceProbeItem["status"]): string { + if (status === "ok") { + return "есть движения"; + } + if (status === "empty") { + return "движения не найдены"; + } + return "ошибка запроса"; +} + function emphasizeNumericTokens(line: string): string { - return line; + if (!line) { + return line; + } + const chunks = line.split(/(`[^`]*`)/g); + return chunks + .map((chunk, index) => { + if (index % 2 === 1) { + return chunk; + } + return chunk.replace(/\b-?(?:\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,]\d+)?\b/g, (match, offset, source) => { + const before = offset > 0 ? source[offset - 1] : ""; + const after = offset + match.length < source.length ? source[offset + match.length] : ""; + if (before === "*" || after === "*") { + return match; + } + return `**${match}**`; + }); + }) + .join(""); } function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null { @@ -219,6 +270,22 @@ function buildIsoDateWithMonthShift( return date.toISOString().slice(0, 10); } +function shiftIsoDateToNextBusinessDay(isoDate: string): string { + const parsed = parseIsoDateToken(isoDate); + if (!parsed) { + return isoDate; + } + const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)); + for (let guard = 0; guard < 10; guard += 1) { + const dayOfWeek = date.getUTCDay(); + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + return date.toISOString().slice(0, 10); + } + date.setUTCDate(date.getUTCDate() + 1); + } + return isoDate; +} + function deriveVatDeadlineCalendar( periodFrom: string | null | undefined, periodTo: string | null | undefined @@ -243,10 +310,12 @@ function deriveVatDeadlineCalendar( const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate(); const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1); const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay); - const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1); - const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1); - const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2); - const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3); + const declarationDueDate = shiftIsoDateToNextBusinessDay( + buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1) + ); + const payment1 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1)); + const payment2 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2)); + const payment3 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3)); return { periodLabel: `${quarterNumber} кв. ${reference.year}`, @@ -384,6 +453,14 @@ function needsVatWhyExplanation(userMessage: string | null | undefined): boolean return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text); } +function needsVatCalendarDetails(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + if (!text) { + return false; + } + return /(?:срок|когда|дата\s+уплат|декларац|дол(?:я|ями)|по\s+частям|платежн(?:ый|ого)\s+график)/iu.test(text); +} + function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number { const text = normalizeQuestionText(userMessage); if (!text) { @@ -1464,6 +1541,9 @@ export function composeFactualReply( rows: ComposeStageRow[], options: ComposeFactualReplyOptions = {} ): { responseType: AddressResponseType; text: string; semantics?: ComposeReplySemantics } { + const applyNumericEmphasis = (line: string): string => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line); + const joinLines = (lines: string[]): string => lines.map(applyNumericEmphasis).join("\n"); + if (intent === "document_type_and_account_section_profile") { const rowsByMarker = new Map(); for (const row of rows) { @@ -2442,32 +2522,72 @@ export function composeFactualReply( const vatActivityDetected = totalVatTurnoverAbs > 0.0000001; const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005; const explainWhyRequested = needsVatWhyExplanation(options.userMessage); + const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage); const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo); + const formatForecastMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); + const vatProbe = options.vatDirectSourceProbe ?? null; + const periodWindowLabel = + options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; const lines = [ - "Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).", - `Строк агрегата: ${rows.length}.`, - `Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`, - `Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`, - `Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`, - `Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`, - `Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`, - `Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.` + `Собран прогноз НДС к уплате: ${formatForecastMoney(vatToPay)}.`, + `Потенциальный перенос/переплата: ${formatForecastMoney(carryoverOrOverpayment)}.`, + `Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`, + "Режим результата: предварительная оценка по проводкам 68.02*/19* (не подтвержденная сумма налога по декларации).", + "", + "База расчета:", + `- Строк агрегата: ${formatNumberWithDots(rows.length)}.`, + `- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`, + `- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`, + `- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`, + `- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.` ]; + if (vatProbe && vatProbe.status === "ok") { + const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; + lines.push( + "", + "Покрытие VAT-источников через MCP:", + `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, + `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, + `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.` + ); + if (vatProbe.probedSources.length > 0) { + lines.push( + ...vatProbe.probedSources.slice(0, 6).map((item, index) => { + const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; + return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`; + }) + ); + } + if (vatProbe.errors.length > 0) { + lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } + lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия."); + } else if (vatProbe && vatProbe.status === "error") { + lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*."); + } + if (!vatActivityDetected) { lines.push( - "В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00." + `В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney( + 0 + )}.` ); } else if (vatToPay === 0 && netVatIsEffectivelyZero) { - lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00."); + lines.push( + `В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате ${formatForecastMoney(0)}.` + ); } else if (vatToPay === 0 && netVat < 0) { - lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00."); + lines.push( + `В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате ${formatForecastMoney(0)}.` + ); } if (vatToPay === 0) { lines.push( + "", "Чеклист проверки в 1С (почему к уплате 0):", - `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`, + `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${periodWindowLabel ?? "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", @@ -2475,7 +2595,7 @@ export function composeFactualReply( ); } - if (vatCalendar) { + if (vatCalendar && shouldShowCalendarDetails) { const periodWindowLabel = vatCalendar.windowFrom && vatCalendar.windowTo ? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}` @@ -2485,18 +2605,20 @@ export function composeFactualReply( const installmentRounded = Number(installmentRaw.toFixed(2)); const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2)); lines.push( + "", `Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, - `Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`, + `Ориентир по долям к уплате: ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С." ); } if (explainWhyRequested) { lines.push( + "", "Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", - `За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`, + `За период 68 Кт = ${formatForecastMoney(turnover68Credit)}, 68 Дт = ${formatForecastMoney(turnover68Debit)}, разница = ${formatForecastMoney(netVat)}.`, netVat <= 0 ? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата." : "Разница положительная, поэтому к уплате берется эта положительная величина.", @@ -2506,7 +2628,7 @@ export function composeFactualReply( return { responseType: "FACTUAL_SUMMARY", - text: lines.join("\n") + text: joinLines(lines) }; } @@ -2570,14 +2692,52 @@ export function composeFactualReply( "", "Блок 2. Что учтено", `- Дата среза: ${formatDateRu(asOfDate)}.`, - "- Контур: остатки по счетам НДС к уплате (68*).", + "- Контур: остатки по счетам НДС к уплате (68*)." + ]; + + const vatProbe = options.vatDirectSourceProbe ?? null; + if (vatProbe && vatProbe.status === "ok") { + const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; + lines.push( + "", + "Блок 2.1. MCP-проверка VAT-источников", + `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, + `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, + `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.` + ); + if (vatProbe.probedSources.length > 0) { + lines.push( + ...vatProbe.probedSources.slice(0, 4).map((item, index) => { + const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; + const suffix = + item.status === "ok" + ? `${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.sampleRegistrator ? ` | пример: ${item.sampleRegistrator}` : ""}` + : item.status === "error" && item.error + ? ` | ошибка: ${item.error}` + : ""; + return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${suffix}`; + }) + ); + } + if (vatProbe.errors.length > 0) { + lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); + } + } else if (vatProbe && vatProbe.status === "error") { + lines.push( + "", + "Блок 2.1. MCP-проверка VAT-источников", + "- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)." + ); + } + + lines.push( "", "Блок 3. Сводка", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Блок 4. Подтвержденные позиции" - ]; + ); if (accountRows.length > 0) { lines.push( @@ -2592,7 +2752,7 @@ export function composeFactualReply( return { responseType: "FACTUAL_LIST", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: "strong", @@ -2732,7 +2892,7 @@ export function composeFactualReply( return { responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", @@ -2812,7 +2972,7 @@ export function composeFactualReply( return { responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium", @@ -2959,7 +3119,7 @@ export function composeFactualReply( ]; return { responseType: "FACTUAL_LIST", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "confirmed_balance", evidence_strength: "strong", @@ -2971,7 +3131,7 @@ export function composeFactualReply( const fallbackLines = buildHeuristicLines(true); return { responseType: "FACTUAL_LIST", - text: fallbackLines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(fallbackLines), semantics: { result_mode: "heuristic_candidates", evidence_strength: counterparties.length > 0 ? "medium" : "weak", @@ -2983,7 +3143,7 @@ export function composeFactualReply( const lines = buildHeuristicLines(false); return { responseType: "FACTUAL_LIST", - text: lines.map(emphasizeNumericTokens).join("\n"), + text: joinLines(lines), semantics: { result_mode: "heuristic_candidates", evidence_strength: counterparties.length > 0 ? "medium" : "weak", diff --git a/llm_normalizer/backend/tests/addressMcpClientEncoding.test.ts b/llm_normalizer/backend/tests/addressMcpClientEncoding.test.ts index ad7bade..48df721 100644 --- a/llm_normalizer/backend/tests/addressMcpClientEncoding.test.ts +++ b/llm_normalizer/backend/tests/addressMcpClientEncoding.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { executeAddressMcpQuery } from "../src/services/addressMcpClient"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { executeAddressMcpMetadata, executeAddressMcpQuery } from "../src/services/addressMcpClient"; const ORIGINAL_FETCH = globalThis.fetch; @@ -44,4 +44,33 @@ describe("address MCP encoding repair", () => { expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК"); expect(result.rows[0]?.["Регистратор"]).toContain("Поступление"); }); + + it("parses get_metadata text-table payload", async () => { + const payload = { + success: true, + data: `[2]{"FullName","Synonym"}: + Document.VATDoc,VAT document + Register.VATRegister,VAT register` + }; + + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { + "content-type": "application/json" + } + }) + ) as typeof fetch; + + const result = await executeAddressMcpMetadata({ + meta_type: "Документ", + name_mask: "ндс", + limit: 10 + }); + + expect(result.error).toBeNull(); + expect(result.fetched_rows).toBe(2); + expect(result.rows[0]?.["FullName"]).toBe("Document.VATDoc"); + expect(result.rows[0]?.["Synonym"]).toBe("VAT document"); + }); }); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 5d5805c..bee20a9 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -1256,6 +1256,7 @@ describe("address compose stage utf8 headers", () => { expect(reply.responseType).toBe("FACTUAL_SUMMARY"); expect(reply.text).toContain("Почему прогноз к уплате 0"); expect(reply.text).toContain("max(0, 68 Кт - 68 Дт)"); + expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00."); expect(reply.text).toContain("За период 68 Кт = 9126.00, 68 Дт = 115342.00, разница = -106216.00."); expect(reply.text).toContain("Разница неположительная"); expect(reply.text).toContain("оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*"); @@ -1283,7 +1284,7 @@ describe("address compose stage utf8 headers", () => { } ], { - userMessage: "сколько НДС нужно заплатить по состоянию на 15 марта 2020 года", + userMessage: "какие сроки уплаты и сдачи декларации по НДС по состоянию на 15 марта 2020 года", periodFrom: "2020-01-01", periodTo: "2020-03-15" } @@ -1292,8 +1293,8 @@ describe("address compose stage utf8 headers", () => { expect(reply.responseType).toBe("FACTUAL_SUMMARY"); expect(reply.text).toContain("Период расчета (срез обязательств): 01.01.2020..15.03.2020."); expect(reply.text).toContain("Налоговый период: 1 кв. 2020."); - expect(reply.text).toContain("Срок сдачи декларации: до 25.04.2020."); - expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 28.06.2020."); + expect(reply.text).toContain("Срок сдачи декларации: до 27.04.2020."); + expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 29.06.2020."); expect(reply.text).toContain("Ориентир по долям к уплате: 100.00 / 100.00 / 100.00."); }); @@ -1311,7 +1312,7 @@ describe("address compose stage utf8 headers", () => { } ], { - userMessage: "прогноз НДС на 31 декабря 2020", + userMessage: "когда платить НДС за 4 квартал 2020", periodFrom: "2020-10-01", periodTo: "2020-12-31" } @@ -1320,7 +1321,7 @@ describe("address compose stage utf8 headers", () => { expect(reply.responseType).toBe("FACTUAL_SUMMARY"); expect(reply.text).toContain("Налоговый период: 4 кв. 2020."); expect(reply.text).toContain("Срок сдачи декларации: до 25.01.2021."); - expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 28.02.2021, 28.03.2021."); + expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 01.03.2021, 29.03.2021."); expect(reply.text).toContain("Ориентир по долям к уплате: 30.00 / 30.00 / 30.00."); }); @@ -1369,7 +1370,7 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("Прогноз НДС к уплате: 0.00."); + expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00."); expect(reply.text).toContain("не найдено движений по НДС-субсчетам 68.02*/19*"); expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):"); expect(reply.text).toContain("Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный"); @@ -1404,10 +1405,160 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("Прогноз НДС к уплате: 0.00."); + expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00."); expect(reply.text).toContain("обороты по 68* взаимно перекрылись"); expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):"); }); + + it("adds MCP VAT source coverage block for VAT forecast response", () => { + const reply = composeFactualReply( + "vat_payable_forecast", + [ + { + period: "2019-12-31T23:59:59Z", + registrator: "VAT_68_CREDIT", + account_dt: "68", + account_kt: "", + amount: 1000, + analytics: [] + } + ], + { + userMessage: "прикинь ндс за декабрь 2019", + periodFrom: "2019-12-01", + periodTo: "2019-12-31", + vatDirectSourceProbe: { + status: "ok", + objectsTotal: 5, + documentsTotal: 2, + registersTotal: 3, + probedSources: [ + { + fullName: "РегистрНакопления.НДСПродажи", + objectType: "register", + status: "ok", + rowsFetched: 1, + lastPeriod: "2019-12-31T23:59:59Z" + }, + { + fullName: "РегистрНакопления.НДСПокупки", + objectType: "register", + status: "empty", + rowsFetched: 0 + } + ], + errors: [] + } + } + ); + + expect(reply.responseType).toBe("FACTUAL_SUMMARY"); + expect(reply.text).toContain("Покрытие VAT-источников через MCP"); + expect(reply.text).toContain("Найдено VAT-объектов: 5"); + expect(reply.text).toContain("РегистрНакопления.НДСПродажи"); + }); + + it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => { + const reply = composeFactualReply( + "vat_payable_forecast", + [ + { + period: "2019-12-31T23:59:59Z", + registrator: "VAT_68_CREDIT", + account_dt: "68", + account_kt: "", + amount: 1234567.89, + analytics: [] + } + ], + { + userMessage: "прикинь ндс", + useRubCurrency: true, + emphasizeNumbers: true + } + ); + + expect(reply.text).toContain("**1.234.567,89** ₽"); + expect(reply.text).toContain("Собран прогноз НДС к уплате:"); + }); + + it("adds MCP VAT source probe block for confirmed VAT as-of response", () => { + const reply = composeFactualReply( + "vat_payable_confirmed_as_of_date", + [ + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: null, + account_kt: "68.02", + amount: 123456.78, + analytics: [] + } + ], + { + asOfDate: "2020-03-31", + vatDirectSourceProbe: { + status: "ok", + objectsTotal: 3, + documentsTotal: 1, + registersTotal: 2, + probedSources: [ + { + fullName: "РегистрНакопления.НДСНачисленный", + objectType: "register", + status: "ok", + rowsFetched: 1, + lastPeriod: "2020-03-31T23:59:59Z", + sampleRegistrator: "Отражение начисления НДС 0001" + }, + { + fullName: "Документ.РегистрацияОплатыНДСВБюджет", + objectType: "document", + status: "empty", + rowsFetched: 0 + } + ], + errors: [] + } + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Блок 2.1. MCP-проверка VAT-источников"); + expect(reply.text).toContain("VAT-объектов в метаданных 1С: 3"); + expect(reply.text).toContain("Источников с движениями до даты среза: 1"); + expect(reply.text).toContain("РегистрНакопления.НДСНачисленный"); + }); + + it("adds VAT probe error note for confirmed VAT as-of response", () => { + const reply = composeFactualReply( + "vat_payable_confirmed_as_of_date", + [ + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: null, + account_kt: "68.02", + amount: 1000, + analytics: [] + } + ], + { + asOfDate: "2020-03-31", + vatDirectSourceProbe: { + status: "error", + objectsTotal: 0, + documentsTotal: 0, + registersTotal: 0, + probedSources: [], + errors: ["metadata timeout"] + } + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Probe VAT-источников завершился ошибкой"); + }); }); describe("address intent resolver expansion (M2.3a)", () => {