diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 4208c9a..bb1e56b 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -129,15 +129,6 @@ 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) { if (!line) { return line; @@ -2127,10 +2118,11 @@ function appendDebtMirrorCompactDisclosure(lines, snapshot, kind) { if (snapshot.mirroredOffsetAmount <= 0.005) { return; } - lines.push(`Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.`); + lines.push("", "Для сверки:"); + lines.push(`- Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.`); const leadingMirror = snapshot.mirrorGroups[0] ?? null; if (leadingMirror) { - lines.push(`Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`); + lines.push(`- Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`); } } function appendDebtMirrorDisclosure(lines, snapshot, kind) { @@ -2975,7 +2967,6 @@ function composeFactualReplyBody(intent, rows, options = {}) { const turnover19Credit = rowsByMarker.get("VAT_19_CREDIT") ?? 0; const netVat = turnover68Credit - turnover68Debit; const vatToPay = Math.max(0, netVat); - const carryoverOrOverpayment = Math.max(0, -netVat); const totalVatTurnoverAbs = Math.abs(turnover68Credit) + Math.abs(turnover68Debit) + Math.abs(turnover19Debit) + Math.abs(turnover19Credit); const vatActivityDetected = totalVatTurnoverAbs > 0.0000001; const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005; @@ -2983,50 +2974,17 @@ function composeFactualReplyBody(intent, rows, options = {}) { 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 = [ `Коротко: ориентир по НДС к уплате за ${periodWindowLabel ?? "доступный срез"} — ${formatForecastMoney(vatToPay)}.`, - `Если смотреть на возможный перенос или переплату, получается ${formatForecastMoney(carryoverOrOverpayment)}.`, - "Это предварительная оценка по оборотам 68.02*/19*, а не подтвержденная сумма налога по декларации.", + "Это предварительная оценка по бухгалтерским оборотам НДС, а не подтвержденная сумма налога по декларации.", "", "Что вошло в расчет:", `- Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`, - `- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`, - `- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`, - `- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`, - `- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.` + `- НДС начисленный: ${formatForecastMoney(turnover68Credit)}.`, + `- Уменьшение/вычеты по НДС: ${formatForecastMoney(turnover68Debit)}.`, + `- Нетто НДС: ${formatForecastMoney(netVat)}.` ]; - if (vatProbe && vatProbe.status === "ok") { - const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; - const statusRank = (status) => status === "ok" ? 0 : status === "empty" ? 1 : 2; - const orderedProbeRows = [...vatProbe.probedSources].sort((a, b) => statusRank(a.status) - statusRank(b.status) || - a.fullName.localeCompare(b.fullName, "ru")); - const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error"); - const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6); - const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; - lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); - if (visibleProbeRows.length > 0) { - lines.push(...visibleProbeRows.map((item, index) => { - const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; - const extra = item.status === "ok" - ? item.lastPeriod - ? ` | последнее движение: ${item.lastPeriod}` - : "" - : item.status === "error" && item.error - ? ` | ошибка: ${item.error}` - : ""; - return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${extra}`; - })); - } - 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-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*."); - } if (!vatActivityDetected) { lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`); } @@ -3073,16 +3031,13 @@ function composeFactualReplyBody(intent, rows, options = {}) { const purchaseVat = rowsByMarker.get("VAT_BOOK_PURCHASES") ?? 0; const netVat = salesVat - purchaseVat; const vatToPay = Math.max(0, netVat); - const carryoverOrOverpayment = Math.max(0, -netVat); const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; const formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); - const vatProbe = options.vatDirectSourceProbe ?? null; const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint); const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : ""; const lines = [ `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`, - `Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`, - "Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.", + "Расчет сделан по книгам продаж и покупок.", "", "Что вошло в расчет:", ...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []), @@ -3091,21 +3046,6 @@ function composeFactualReplyBody(intent, rows, options = {}) { `- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`, `- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.` ]; - if (vatProbe && vatProbe.status === "ok") { - const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; - const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; - lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); - if (vatProbe.errors.length > 0) { - lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); - } - lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников."); - } - else if (vatProbe && vatProbe.status === "error") { - lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия."); - if (vatProbe.errors.length > 0) { - lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); - } - } if (rows.length === 0) { lines.push("", "За выбранный налоговый период не найдены строки книг продаж/покупок, поэтому подтвержденная сумма к уплате равна 0."); } @@ -3161,40 +3101,14 @@ function composeFactualReplyBody(intent, rows, options = {}) { const lines = [ `Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`, "", - "Блок 1. Статус результата", - "- Результат: подтвержденный срез НДС к уплате по состоянию на дату.", - "", - "Блок 2. Что учтено", + "Что учтено:", `- Дата среза: ${formatDateRu(asOfDate)}.`, - "- Контур: остатки по счетам НДС к уплате (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. Проверка VAT-источников в 1С", `- 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. Проверка VAT-источников в 1С", "- Дополнительная проверка VAT-источников завершилась ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."); - } - lines.push("", "Блок 3. Сводка", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Блок 4. Подтвержденные позиции"); + lines.push("", "Сводка:", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Подтвержденные позиции:"); if (accountRows.length > 0) { lines.push(...accountRows.slice(0, 12).map((item, index) => { - const refs = Array.from(item.refs).slice(0, 2).join("; "); - return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`; + return `${index + 1}. ${item.account} — остаток НДС к уплате: ${formatMoneyRub(item.total)}, операций: ${formatNumberWithDots(item.operations)}.`; })); } else { diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 363f7a8..22521bf 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -94,7 +94,14 @@ function composeInventoryReply(intent, rows, options, deps) { const lines = [directAnswerLine]; if (positions.length > 0) { const visiblePositionsLimit = 6; - const visiblePositions = positions.slice(0, visiblePositionsLimit); + const positionsByAmount = [...positions].sort((left, right) => { + const amountDelta = right.amount - left.amount; + if (amountDelta !== 0) { + return amountDelta; + } + return left.item.localeCompare(right.item, "ru"); + }); + const visiblePositions = positionsByAmount.slice(0, visiblePositionsLimit); (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", visiblePositions.map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, { formatDateRu: deps.formatDateRu, formatNumberWithDots: deps.formatNumberWithDots, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index c656e3a..c9524ec 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -77,15 +77,54 @@ function aggregationAxisForPlanner(planner) { const axis = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_aggregation_axis)?.toLowerCase(); return axis === "month" ? "month" : null; } +function cleanExplicitEntityCandidate(value) { + let text = value.replace(/\s+/g, " ").trim(); + if (!text) { + return null; + } + text = text + .replace(/^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu, "") + .replace(/^(?:по\s+)?(?:контрагент(?:у|ом)?|поставщик(?:у|ом)?|клиент(?:у|ом)?)\s+/iu, "") + .replace(/\s+за\s+\d{4}(?:\s+год)?$/iu, "") + .trim(); + return text || null; +} +function scoreExplicitEntityCandidate(raw, cleaned, index) { + let score = 100 - index; + if (raw.trim() === cleaned) { + score += 20; + } + if (/^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu.test(raw)) { + score -= 40; + } + if (/[А-ЯЁA-Z]/u.test(cleaned)) { + score += 8; + } + if (/(?:^|\s)(?:ООО|АО|ПАО|ИП|Группа|Комитет|Департамент)(?:\s|$)/u.test(cleaned)) { + score += 6; + } + score -= Math.max(0, cleaned.length - 60); + return score; +} function firstEntityCandidate(planner) { const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; - for (const candidate of candidates) { - const text = toNonEmptyString(candidate); - if (text) { - return text; + let best = null; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + const raw = toNonEmptyString(candidate); + if (!raw) { + continue; + } + const cleaned = cleanExplicitEntityCandidate(raw); + if (!cleaned) { + continue; + } + const score = scoreExplicitEntityCandidate(raw, cleaned, index); + if (!best || score > best.score) { + best = { text: cleaned, score }; } } - return null; + return best?.text ?? null; } function dateScopeToFilters(dateScope) { if (!dateScope) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index e26b86d..92f1f63 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -931,6 +931,58 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { lines.push("Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; косвенные признаки нельзя выдавать за доказанный факт резерва."); return joinBusinessReplyLines(lines); } + if (!separateSubject && !crossScopeExecutiveSummary && (actionFamily === "broad_evaluation" || unsupportedFamily === "broad_business_evaluation")) { + const subject = organizationScope ?? "компания"; + const periodWithoutPrefix = period.replace(/^за\s+/iu, ""); + lines.push(`Коротко: по доступным данным ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж.`); + lines.push("Что видно:"); + if (incomingAmount) { + lines.push(`- входящие деньги за ${periodWithoutPrefix}: ${incomingAmount};`); + } + if (outgoingAmount) { + lines.push(`- исходящие платежи/списания: ${outgoingAmount};`); + } + if (netAmount) { + lines.push(`- ${netDirection}: ${sentenceAmount(netAmount) ?? netAmount};`); + } + if (customerName && customerAmount) { + lines.push(topCustomerLooksFinancial + ? `- крупнейший входящий источник: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}; это похоже на финансовый контур, не на обычную клиентскую выручку;` + : `- крупнейший источник денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}; это сигнал концентрации на крупном заказчике;`); + } + if (topSupplier) { + lines.push(topSupplierLooksFinancial + ? `- крупнейший получатель исходящих денег: ${topSupplier}; это похоже на финансовый контур, не на обычного поставщика;` + : `- крупнейший получатель исходящих денег: ${topSupplier};`); + } + const inventoryLine = businessOverviewInventoryLine(overview); + if (inventoryLine) { + lines.push(`- ${localizeLine(inventoryLine)}`); + } + const debtLine = businessOverviewDebtLine(overview); + if (debtLine) { + lines.push(`- ${localizeLine(debtLine)}`); + } + lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль."); + const missingOverviewFamilies = []; + if (!businessOverviewTaxLine(overview)) { + missingOverviewFamilies.push("НДС/налоговая позиция без отдельного точного расчета"); + } + if (!debtLine) { + missingOverviewFamilies.push("долги без даты среза"); + } + if (!inventoryLine) { + missingOverviewFamilies.push("склад без даты среза"); + } + if (missingOverviewFamilies.length > 0) { + lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`); + } + if (limitLine) { + lines.push(limitLine); + } + lines.push("Что проверить дальше: чистую прибыль через 90/91/99, маржинальность по проектам, зависимость от топ-3 контрагентов, старые складские остатки и зависшие расчеты."); + return joinBusinessReplyLines(lines); + } if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) { lines.push(`Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.`); lines.push(previousCounterpartySummary.line); diff --git a/llm_normalizer/backend/dist/services/capabilitiesRegistry.js b/llm_normalizer/backend/dist/services/capabilitiesRegistry.js index 1aa2773..23c7e4f 100644 --- a/llm_normalizer/backend/dist/services/capabilitiesRegistry.js +++ b/llm_normalizer/backend/dist/services/capabilitiesRegistry.js @@ -198,21 +198,21 @@ function loadCapabilitiesRegistry() { } } function buildCapabilityContractReplyFromRegistry() { - const registry = loadCapabilitiesRegistry(); - const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6); - const groupLines = topGroups.map((group, index) => { - const examples = group.typical_queries - .slice(0, 2) - .map((query) => query.trim()) - .filter((query) => query.length > 0) - .join("; "); - return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`; - }); return [ - "Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.", - "По основным группам:", - ...groupLines, - "Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.", + "Могу быстро смотреть управленческие вещи по данным 1С в режиме чтения:", + "- кто должен денег и кому должны;", + "- какой год или месяц был самым денежным;", + "- какие контрагенты дают основной поток;", + "- что лежит на складе и какие остатки стареют;", + "- сколько НДС к уплате за период;", + "- какие документы, оплаты и договоры есть по контрагенту.", + "", + "Примеры запросов:", + "- кто самый доходный клиент за все время", + "- что зависло на складе", + "- кому мы должны на сегодня", + "- какое нетто по СВК за 2020", + "- сколько НДС к уплате за 4 квартал 2019", "Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере." ].join("\n"); } diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index ef887e4..4ef8345 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -237,16 +237,6 @@ 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 { if (!line) { return line; @@ -2766,12 +2756,13 @@ function appendDebtMirrorCompactDisclosure( if (snapshot.mirroredOffsetAmount <= 0.005) { return; } + lines.push("", "Для сверки:"); lines.push( - `Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.` + `- Отдельно сверено встречных остатков: ${formatMoneyRub(snapshot.mirroredOffsetAmount)}; они не включены в ${debtMirrorCleanScopeLabel(kind)}.` ); const leadingMirror = snapshot.mirrorGroups[0] ?? null; if (leadingMirror) { - lines.push(`Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`); + lines.push(`- Крупнейший встречный хвост: ${formatDebtMirrorGroupLine(leadingMirror)}`); } } @@ -3801,7 +3792,6 @@ function composeFactualReplyBody( const netVat = turnover68Credit - turnover68Debit; const vatToPay = Math.max(0, netVat); - const carryoverOrOverpayment = Math.max(0, -netVat); const totalVatTurnoverAbs = Math.abs(turnover68Credit) + Math.abs(turnover68Debit) + Math.abs(turnover19Debit) + Math.abs(turnover19Credit); const vatActivityDetected = totalVatTurnoverAbs > 0.0000001; @@ -3810,67 +3800,20 @@ function composeFactualReplyBody( 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 = [ `Коротко: ориентир по НДС к уплате за ${periodWindowLabel ?? "доступный срез"} — ${formatForecastMoney(vatToPay)}.`, - `Если смотреть на возможный перенос или переплату, получается ${formatForecastMoney(carryoverOrOverpayment)}.`, - "Это предварительная оценка по оборотам 68.02*/19*, а не подтвержденная сумма налога по декларации.", + "Это предварительная оценка по бухгалтерским оборотам НДС, а не подтвержденная сумма налога по декларации.", "", "Что вошло в расчет:", `- Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`, - `- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`, - `- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`, - `- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`, - `- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.` + `- НДС начисленный: ${formatForecastMoney(turnover68Credit)}.`, + `- Уменьшение/вычеты по НДС: ${formatForecastMoney(turnover68Debit)}.`, + `- Нетто НДС: ${formatForecastMoney(netVat)}.` ]; - if (vatProbe && vatProbe.status === "ok") { - const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; - const statusRank = (status: VatDirectSourceProbeItem["status"]): number => - status === "ok" ? 0 : status === "empty" ? 1 : 2; - const orderedProbeRows = [...vatProbe.probedSources].sort( - (a, b) => - statusRank(a.status) - statusRank(b.status) || - a.fullName.localeCompare(b.fullName, "ru") - ); - const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error"); - const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6); - const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; - lines.push( - "", - "Покрытие VAT-источников в 1С:", - `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, - `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, - `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, - `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.` - ); - if (visibleProbeRows.length > 0) { - lines.push( - ...visibleProbeRows.map((item, index) => { - const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; - const extra = - item.status === "ok" - ? item.lastPeriod - ? ` | последнее движение: ${item.lastPeriod}` - : "" - : item.status === "error" && item.error - ? ` | ошибка: ${item.error}` - : ""; - return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${extra}`; - }) - ); - } - 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-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*."); - } - if (!vatActivityDetected) { lines.push( `В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney( @@ -3950,18 +3893,15 @@ function composeFactualReplyBody( const purchaseVat = rowsByMarker.get("VAT_BOOK_PURCHASES") ?? 0; const netVat = salesVat - purchaseVat; const vatToPay = Math.max(0, netVat); - const carryoverOrOverpayment = Math.max(0, -netVat); const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); - const vatProbe = options.vatDirectSourceProbe ?? null; const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint); const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : ""; const lines = [ `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`, - `Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`, - "Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.", + "Расчет сделан по книгам продаж и покупок.", "", "Что вошло в расчет:", ...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []), @@ -3971,32 +3911,6 @@ function composeFactualReplyBody( `- Нетто НДС (книга продаж - книга покупок): ${formatConfirmedMoney(netVat)}.` ]; - if (vatProbe && vatProbe.status === "ok") { - const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; - const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; - lines.push( - "", - "Покрытие VAT-источников в 1С:", - `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, - `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, - `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, - `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.` - ); - if (vatProbe.errors.length > 0) { - lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); - } - lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников."); - } else if (vatProbe && vatProbe.status === "error") { - lines.push( - "", - "Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.", - "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия." - ); - if (vatProbe.errors.length > 0) { - lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); - } - } - if (rows.length === 0) { lines.push( "", @@ -4070,63 +3984,24 @@ function composeFactualReplyBody( const lines: string[] = [ `Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`, "", - "Блок 1. Статус результата", - "- Результат: подтвержденный срез НДС к уплате по состоянию на дату.", - "", - "Блок 2. Что учтено", + "Что учтено:", `- Дата среза: ${formatDateRu(asOfDate)}.`, - "- Контур: остатки по счетам НДС к уплате (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. Проверка VAT-источников в 1С", - `- 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. Проверка VAT-источников в 1С", - "- Дополнительная проверка 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("; "); - return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`; + return `${index + 1}. ${item.account} — остаток НДС к уплате: ${formatMoneyRub(item.total)}, операций: ${formatNumberWithDots(item.operations)}.`; }) ); } else { diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index abb36f6..1b49c13 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -184,7 +184,14 @@ export function composeInventoryReply( if (positions.length > 0) { const visiblePositionsLimit = 6; - const visiblePositions = positions.slice(0, visiblePositionsLimit); + const positionsByAmount = [...positions].sort((left, right) => { + const amountDelta = right.amount - left.amount; + if (amountDelta !== 0) { + return amountDelta; + } + return left.item.localeCompare(right.item, "ru"); + }); + const visiblePositions = positionsByAmount.slice(0, visiblePositionsLimit); appendInventorySection( lines, "Позиции:", diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index 566e1a4..ab0ed5f 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -717,15 +717,59 @@ function aggregationAxisForPlanner( return axis === "month" ? "month" : null; } +function cleanExplicitEntityCandidate(value: string): string | null { + let text = value.replace(/\s+/g, " ").trim(); + if (!text) { + return null; + } + text = text + .replace( + /^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu, + "" + ) + .replace(/^(?:по\s+)?(?:контрагент(?:у|ом)?|поставщик(?:у|ом)?|клиент(?:у|ом)?)\s+/iu, "") + .replace(/\s+за\s+\d{4}(?:\s+год)?$/iu, "") + .trim(); + return text || null; +} + +function scoreExplicitEntityCandidate(raw: string, cleaned: string, index: number): number { + let score = 100 - index; + if (raw.trim() === cleaned) { + score += 20; + } + if (/^(?:по\s+)?(?:деньгам|деньги|денежн(?:ый|ые|ого|ому|ым|ом)?\s+поток(?:у|ом|а|и)?|нетто|расчет(?:ам|ы)?|получили|получено|заплатили|уплачено|сколько)\s+(?:с|по)\s+/iu.test(raw)) { + score -= 40; + } + if (/[А-ЯЁA-Z]/u.test(cleaned)) { + score += 8; + } + if (/(?:^|\s)(?:ООО|АО|ПАО|ИП|Группа|Комитет|Департамент)(?:\s|$)/u.test(cleaned)) { + score += 6; + } + score -= Math.max(0, cleaned.length - 60); + return score; +} + function firstEntityCandidate(planner: AssistantMcpDiscoveryPlannerContract): string | null { const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; - for (const candidate of candidates) { - const text = toNonEmptyString(candidate); - if (text) { - return text; + let best: { text: string; score: number } | null = null; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + const raw = toNonEmptyString(candidate); + if (!raw) { + continue; + } + const cleaned = cleanExplicitEntityCandidate(raw); + if (!cleaned) { + continue; + } + const score = scoreExplicitEntityCandidate(raw, cleaned, index); + if (!best || score > best.score) { + best = { text: cleaned, score }; } } - return null; + return best?.text ?? null; } function dateScopeToFilters(dateScope: string | null): Pick { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index b8455bb..24c432f 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -1110,6 +1110,69 @@ function buildCompactBusinessOverviewReply( return joinBusinessReplyLines(lines); } + if (!separateSubject && !crossScopeExecutiveSummary && (actionFamily === "broad_evaluation" || unsupportedFamily === "broad_business_evaluation")) { + const subject = organizationScope ?? "компания"; + const periodWithoutPrefix = period.replace(/^за\s+/iu, ""); + lines.push( + `Коротко: по доступным данным ${subject} выглядит как бизнес с крупными контрактными денежными потоками и заметной зависимостью от нескольких крупных контрагентов, а не как равномерный поток мелких продаж.` + ); + lines.push("Что видно:"); + if (incomingAmount) { + lines.push(`- входящие деньги за ${periodWithoutPrefix}: ${incomingAmount};`); + } + if (outgoingAmount) { + lines.push(`- исходящие платежи/списания: ${outgoingAmount};`); + } + if (netAmount) { + lines.push(`- ${netDirection}: ${sentenceAmount(netAmount) ?? netAmount};`); + } + if (customerName && customerAmount) { + lines.push( + topCustomerLooksFinancial + ? `- крупнейший входящий источник: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}; это похоже на финансовый контур, не на обычную клиентскую выручку;` + : `- крупнейший источник денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}; это сигнал концентрации на крупном заказчике;` + ); + } + if (topSupplier) { + lines.push( + topSupplierLooksFinancial + ? `- крупнейший получатель исходящих денег: ${topSupplier}; это похоже на финансовый контур, не на обычного поставщика;` + : `- крупнейший получатель исходящих денег: ${topSupplier};` + ); + } + const inventoryLine = businessOverviewInventoryLine(overview); + if (inventoryLine) { + lines.push(`- ${localizeLine(inventoryLine)}`); + } + const debtLine = businessOverviewDebtLine(overview); + if (debtLine) { + lines.push(`- ${localizeLine(debtLine)}`); + } + lines.push( + "Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль." + ); + const missingOverviewFamilies: string[] = []; + if (!businessOverviewTaxLine(overview)) { + missingOverviewFamilies.push("НДС/налоговая позиция без отдельного точного расчета"); + } + if (!debtLine) { + missingOverviewFamilies.push("долги без даты среза"); + } + if (!inventoryLine) { + missingOverviewFamilies.push("склад без даты среза"); + } + if (missingOverviewFamilies.length > 0) { + lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`); + } + if (limitLine) { + lines.push(limitLine); + } + lines.push( + "Что проверить дальше: чистую прибыль через 90/91/99, маржинальность по проектам, зависимость от топ-3 контрагентов, старые складские остатки и зависшие расчеты." + ); + return joinBusinessReplyLines(lines); + } + if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) { lines.push( `Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.` diff --git a/llm_normalizer/backend/src/services/capabilitiesRegistry.ts b/llm_normalizer/backend/src/services/capabilitiesRegistry.ts index 02c4ba4..abc57da 100644 --- a/llm_normalizer/backend/src/services/capabilitiesRegistry.ts +++ b/llm_normalizer/backend/src/services/capabilitiesRegistry.ts @@ -219,22 +219,21 @@ export function loadCapabilitiesRegistry(): CapabilityRegistry { } export function buildCapabilityContractReplyFromRegistry(): string { - const registry = loadCapabilitiesRegistry(); - const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6); - const groupLines = topGroups.map((group, index) => { - const examples = group.typical_queries - .slice(0, 2) - .map((query) => query.trim()) - .filter((query) => query.length > 0) - .join("; "); - return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`; - }); - return [ - "Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.", - "По основным группам:", - ...groupLines, - "Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.", + "Могу быстро смотреть управленческие вещи по данным 1С в режиме чтения:", + "- кто должен денег и кому должны;", + "- какой год или месяц был самым денежным;", + "- какие контрагенты дают основной поток;", + "- что лежит на складе и какие остатки стареют;", + "- сколько НДС к уплате за период;", + "- какие документы, оплаты и договоры есть по контрагенту.", + "", + "Примеры запросов:", + "- кто самый доходный клиент за все время", + "- что зависло на складе", + "- кому мы должны на сегодня", + "- какое нетто по СВК за 2020", + "- сколько НДС к уплате за 4 квартал 2019", "Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере." ].join("\n"); } diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index c039601..4d6dafd 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2092,9 +2092,11 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("Покрытие VAT-источников в 1С"); - expect(reply.text).toContain("Найдено VAT-объектов: 5"); - expect(reply.text).toContain("РегистрНакопления.НДСПродажи"); + expect(reply.text).toContain("предварительная оценка по бухгалтерским оборотам НДС"); + expect(reply.text).toContain("НДС начисленный"); + expect(reply.text).not.toContain("Покрытие VAT-источников в 1С"); + expect(reply.text).not.toContain("VAT-объектов"); + expect(reply.text).not.toContain("РегистрНакопления.НДСПродажи"); }); it("builds confirmed VAT tax-period reply from sales and purchase book markers", () => { @@ -2194,7 +2196,7 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).not.toContain("**1**)"); }); - it("keeps VAT probe timestamps intact when numeric emphasis is enabled", () => { + it("keeps VAT probe diagnostics out of the user-facing estimate when numeric emphasis is enabled", () => { const reply = composeFactualReply( "vat_payable_forecast", [ @@ -2229,11 +2231,12 @@ describe("address compose stage utf8 headers", () => { } ); - expect(reply.text).toContain("последнее движение: 2019-12-31T23:59:59Z"); + expect(reply.text).toContain("предварительная оценка по бухгалтерским оборотам НДС"); + expect(reply.text).not.toContain("последнее движение: 2019-12-31T23:59:59Z"); expect(reply.text).not.toContain("2019****-12**-31T23:**59**:59Z"); }); - it("adds MCP VAT source probe block for confirmed VAT as-of response", () => { + it("hides MCP VAT source probe diagnostics for confirmed VAT as-of response", () => { const reply = composeFactualReply( "vat_payable_confirmed_as_of_date", [ @@ -2275,10 +2278,10 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_LIST"); - expect(reply.text).toContain("Блок 2.1. Проверка VAT-источников в 1С"); - expect(reply.text).toContain("VAT-объектов в метаданных 1С: 3"); - expect(reply.text).toContain("Источников с движениями до даты среза: 1"); - expect(reply.text).toContain("РегистрНакопления.НДСНачисленный"); + expect(reply.text).toContain("Что учтено:"); + expect(reply.text).toContain("Подтвержденные позиции:"); + expect(reply.text).not.toContain("VAT-объектов"); + expect(reply.text).not.toContain("РегистрНакопления.НДСНачисленный"); }); it("adds VAT probe error note for confirmed VAT as-of response", () => { @@ -2308,7 +2311,8 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_LIST"); - expect(reply.text).toContain("Дополнительная проверка VAT-источников завершилась ошибкой"); + expect(reply.text).toContain("Подтвержденные позиции:"); + expect(reply.text).not.toContain("Дополнительная проверка VAT-источников завершилась ошибкой"); }); }); @@ -5727,6 +5731,47 @@ it("routes old purchase residue questions to aging-by-purchase-date", () => { expect(reply.semantics?.balance_confirmed).toBe(true); }); + it("sorts inventory-on-hand positions by amount before rendering the top list", () => { + const reply = composeFactualReply( + "inventory_on_hand_as_of_date", + [ + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: "41.01", + account_kt: "", + amount: 148261.67, + analytics: ["Модуль прямоугольный", "Основной склад", "ООО Ромашка"], + item: "Модуль прямоугольный", + warehouse: "Основной склад", + organization: "ООО Ромашка", + quantity: 22 + }, + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: "41.01", + account_kt: "", + amount: 498472.5, + analytics: ["Конструкция трансформер рабочей станции", "Основной склад", "ООО Ромашка"], + item: "Конструкция трансформер рабочей станции", + warehouse: "Основной склад", + organization: "ООО Ромашка", + quantity: 3 + } + ], + { + asOfDate: "2020-03-31", + useRubCurrency: true + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text.indexOf("Конструкция трансформер рабочей станции")).toBeLessThan( + reply.text.indexOf("Модуль прямоугольный") + ); + }); + it("keeps supplier-overlap reply business-first without exact contour leakage", () => { const reply = composeFactualReply( "inventory_supplier_stock_overlap_as_of_date", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index 66c0fea..a838d7a 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -1750,6 +1750,51 @@ describe("assistant MCP discovery pilot executor", () => { expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2); }); + it("prefers clean counterparty candidates for bidirectional net value-flow probes", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["деньгам с группа свк", "Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" + } + }); + const deps = buildSequentialDeps([ + { + rows: [{ Period: "2020-06-30T00:00:00", Amount: 12093465, Counterparty: "Группа СВК" }] + }, + { + rows: [] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.derived_bidirectional_value_flow).toMatchObject({ + counterparty: "Группа СВК", + period_scope: "2020", + net_amount: 12093465, + net_direction: "net_incoming", + incoming_customer_revenue: { + rows_matched: 1, + total_amount: 12093465 + }, + outgoing_supplier_payout: { + rows_matched: 0, + total_amount: 0 + } + }); + expect(result.reason_codes).toContain("pilot_derived_bidirectional_value_flow_from_confirmed_rows"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2); + const serializedCalls = deps.executeAddressMcpQuery.mock.calls + .map((call) => JSON.stringify(call[0])) + .join("\n"); + expect(serializedCalls).toContain("%Группа%"); + expect(serializedCalls).toContain("%СВК%"); + expect(serializedCalls).not.toContain("деньгам с группа свк"); + }); + it("preserves explicit date ranges when building bidirectional value-flow probes", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: {