diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index da4c5d5..1c17da0 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -497,6 +497,9 @@ function classifyPayablesLiabilityCategory(row, counterparty) { }; const reasons = new Set(); const text = `${counterparty} ${row.registrator} ${row.analytics.join(" ")}`.toLowerCase(); + const hasBankOrCreditSignal = /(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit|bank)/iu.test(text); + const hasTaxOrStateSignal = /(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал|налогов)/iu.test(text); + const hasCommercialCounterpartySignal = /(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor|заказчик|клиент|counterparty)/iu.test(text); const accountPrefixes = [extractAccountSectionCode(row.account_dt), extractAccountSectionCode(row.account_kt)].filter((item) => Boolean(item)); if (accountPrefixes.includes("60")) { scores.supplier_or_contractor += 3; @@ -511,18 +514,20 @@ function classifyPayablesLiabilityCategory(row, counterparty) { reasons.add("участие счета 68/69"); } if (accountPrefixes.includes("76")) { - scores.supplier_or_contractor += 1; - reasons.add("участие счета 76"); + scores.other += 1; + reasons.add("участие счета 76 (прочие расчеты)"); } - if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) { - scores.bank_or_credit += 3; + if (hasBankOrCreditSignal) { + scores.bank_or_credit += 6; + scores.supplier_or_contractor = Math.max(0, scores.supplier_or_contractor - 2); reasons.add("банк/кредит в аналитике"); } - if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) { - scores.tax_or_state += 3; + if (hasTaxOrStateSignal) { + scores.tax_or_state += 6; + scores.supplier_or_contractor = Math.max(0, scores.supplier_or_contractor - 2); reasons.add("налог/госорган в аналитике"); } - if (/(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor)/iu.test(text)) { + if (hasCommercialCounterpartySignal && !hasBankOrCreditSignal && !hasTaxOrStateSignal) { scores.supplier_or_contractor += 2; reasons.add("коммерческий контрагент в аналитике"); } @@ -660,6 +665,8 @@ function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) { continue; } const classified = classifyPayablesLiabilityCategory(row, name); + const contract = extractContractName(row); + const sourceRefs = extractPayablesSourceRefs(row, name, contract); const current = byCounterparty.get(name); if (!current) { byCounterparty.set(name, { @@ -673,7 +680,10 @@ function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) { tax_or_state: classified.scores.tax_or_state, other: classified.scores.other }, - reasons: new Set(classified.reasons) + reasons: new Set(classified.reasons), + contracts: new Set(contract ? [contract] : []), + documents: new Set(row.registrator ? [row.registrator] : []), + sourceRefs: new Set(sourceRefs) }); continue; } @@ -692,6 +702,15 @@ function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) { for (const reason of classified.reasons) { current.reasons.add(reason); } + if (contract) { + current.contracts.add(contract); + } + if (row.registrator) { + current.documents.add(row.registrator); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } } return Array.from(byCounterparty.entries()) .map(([name, item]) => ({ @@ -701,7 +720,10 @@ function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) { firstPeriod: item.firstPeriod, lastPeriod: item.lastPeriod, category: resolvePayablesLiabilityCategory(item.categoryScores), - categoryReasons: Array.from(item.reasons).slice(0, 2) + categoryReasons: Array.from(item.reasons).slice(0, 2), + contracts: Array.from(item.contracts).slice(0, 2), + documents: Array.from(item.documents).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) })) .filter((item) => item.outstandingAmount > 0.005) .sort((left, right) => { @@ -887,6 +909,59 @@ function extractContractName(row) { } return null; } +function normalizeEntityToken(value) { + return String(value ?? "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/\s+/g, " ") + .trim(); +} +function extractPayablesSourceRefs(row, counterparty, contract) { + const refs = new Set(); + const counterpartyToken = normalizeEntityToken(counterparty); + const contractToken = normalizeEntityToken(contract); + const sourceRefTokenPattern = /(?:№|договор|contract|счет|сч[её]т|акт|накладн|плат[её]ж|invoice|payment|order|заявк|реестр|доп\.?\s*соглаш)/iu; + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) { + continue; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + continue; + } + const tokenNormalized = normalizeEntityToken(normalized); + if (!tokenNormalized) { + continue; + } + if (tokenNormalized === counterpartyToken || (contractToken && tokenNormalized === contractToken)) { + continue; + } + if (sourceRefTokenPattern.test(normalized) || /(?:[a-zа-я].*\d|\d.*[a-zа-я])/iu.test(normalized)) { + refs.add(normalized); + } + if (refs.size >= 3) { + break; + } + } + return Array.from(refs); +} +function formatPayablesEvidenceSuffix(item) { + const parts = []; + if (item.contracts.length > 0) { + parts.push(`договор: ${item.contracts.slice(0, 2).join("; ")}`); + } + if (item.documents.length > 0) { + const suffix = item.documents.length > 1 ? ` (+${item.documents.length - 1})` : ""; + parts.push(`документ: ${item.documents[0]}${suffix}`); + } + if (item.sourceRefs.length > 0) { + parts.push(`source refs: ${item.sourceRefs.slice(0, 2).join("; ")}`); + } + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} function deriveOperationalYearWindow(yearDocs, yearOps) { const docsSeries = [...yearDocs].sort((a, b) => a.year - b.year); const fallbackSeries = [...yearOps].sort((a, b) => a.year - b.year); @@ -1816,11 +1891,9 @@ function composeFactualReply(intent, rows, options = {}) { const asOfDate = normalizeIsoDateOnly(options.asOfDate); const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodTo = normalizeIsoDateOnly(options.periodTo); - const scopeLine = asOfDate - ? `- Дата среза: ${formatDateRu(asOfDate)}.` - : periodFrom || periodTo - ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` - : null; + const periodScopeLine = !asOfDate && (periodFrom || periodTo) + ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` + : null; const carryoverLine = asOfDate || periodFrom || periodTo ? "- В срез могут входить обязательства, возникшие до периода, если они оставались открытыми на дату среза." : null; @@ -1836,8 +1909,8 @@ function composeFactualReply(intent, rows, options = {}) { lines.push(""); lines.push("Блок 2. Что учтено"); lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`); - if (scopeLine) { - lines.push(scopeLine); + if (periodScopeLine) { + lines.push(periodScopeLine); } lines.push("- Контур: обязательства по счетам 60/76."); if (carryoverLine) { @@ -1856,7 +1929,7 @@ function composeFactualReply(intent, rows, options = {}) { lines.push(""); lines.push("Блок 5. Подтвержденные позиции к оплате"); if (confirmedBalances.length > 0) { - lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`)); + lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`)); } else { lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено."); @@ -1977,8 +2050,8 @@ function composeFactualReply(intent, rows, options = {}) { `- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`, `- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`, "", - "Блок 5. Кому нужно заплатить в первую очередь (по сумме остатка):", - ...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`) + "Блок 5. Крупнейшие подтвержденные позиции к оплате (по сумме остатка):", + ...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`) ]; return { responseType: "FACTUAL_LIST", diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 2e82d15..0810c04 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3792,6 +3792,28 @@ function resolveAssistantOrchestrationDecision(input) { hasAccountingSignal(repairedEffectiveAddressUserMessage) || hasDataRetrievalRequestSignal(rawUserMessage) || hasDataRetrievalRequestSignal(repairedRawUserMessage); + const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); + const llmFirstAddressCandidate = Boolean(llmContractMode === "address_query" && llmContractIntent && llmContractIntent !== "unknown"); + const llmFirstUnsupportedCandidate = Boolean(llmContractMode === "unsupported" && + (!llmContractIntent || llmContractIntent === "unknown")); + const dangerOrCoercionSignal = hasDangerOrCoercionSignal(rawUserMessage) || + hasDangerOrCoercionSignal(repairedRawUserMessage) || + hasDangerOrCoercionSignal(effectiveAddressUserMessage) || + hasDangerOrCoercionSignal(repairedEffectiveAddressUserMessage); + const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) || + hasAddressFollowupContextSignal(repairedRawUserMessage) || + hasAddressFollowupContextSignal(effectiveAddressUserMessage) || + hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage); + const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; + const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && + !capabilityMetaQuery && + !dataRetrievalSignal && + !effectiveAddressFollowupSignal && + modeDetection.mode === "unsupported" && + intentResolution.intent === "unknown"); + const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && + deterministicNonDomainGuard && + (llmFirstUnsupportedCandidate || llmContractMode === null)); const hardMetaMode = dataScopeMetaQuery ? "data_scope" : capabilityMetaQuery && !dataRetrievalSignal @@ -3853,8 +3875,35 @@ function resolveAssistantOrchestrationDecision(input) { } }; } + if (nonDomainQueryIndexed) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "non_domain_query_indexed", + livingMode: "chat", + livingReason: "non_domain_query_indexed", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: "non_domain", + address_mode: modeDetection.mode, + address_mode_confidence: modeDetection.confidence, + address_intent: intentResolution.intent, + address_intent_confidence: intentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + followup_context_detected: Boolean(followupContext), + unsupported_address_intent_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "non_domain_query_indexed", + living_mode: "chat", + living_reason: "non_domain_query_indexed" + } + } + }; + } const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); - const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -4160,11 +4209,11 @@ function hasOperationalAdminActionRequestSignal(text) { return /(?:удаляй?\s+баз|удали\s+баз|снеси\s+баз|delete\s+database|drop\s+database)/i.test(normalized); } function hasDangerOrCoercionSignal(text) { - const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е"); - if (!lower) { + const normalized = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е"); + if (!normalized) { return false; } - return /(?:убьют|убьют|убью|убить|убиют|угрож|опасн|омон|полици|насили|заставля|принужда|шантаж)/i.test(lower); + return /(?:убью|убить|убьют|убиют|угрож|опасн|омон|полици|насили|заставля|принужда|шантаж|выпил(?:юсь|иться|ился|илась|иться)|суицид|самоубий|покончу\s+с\s+собой|не\s+хочу\s+жить|хочу\s+умереть|сдохнуть|вскрыть\s+вены|повешусь|повеситься|спрыгну|kill\s+myself|end\s+my\s+life|suicid|self[\s-]?harm)/iu.test(normalized); } function hasDestructiveDataActionSignal(text) { const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е"); @@ -4282,6 +4331,9 @@ function hasLivingChatSignal(text) { if (!lower) { return false; } + if (/^(?:а\s+)?(?:тут|здесь|там|сюда|туда)[\s!?.,:;\-]*$/iu.test(lower)) { + return true; + } if (/^(ага|угу|ок|окей|ясно|понял|поняла|принято|спасибо|благодарю|супер|класс|норм|го|давай|погнали|привет|хай|йо|yo|че\s+там|ч[её]\s+как|че\s+как|hello|hi|thanks?)$/i.test(lower)) { return true; } diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index de941a5..d2b5fbb 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -653,6 +653,9 @@ interface PayablesConfirmedBalanceAggregate { lastPeriod: string | null; category: PayablesLiabilityCategory; categoryReasons: string[]; + contracts: string[]; + documents: string[]; + sourceRefs: string[]; } function liabilityCategoryLabel(category: PayablesLiabilityCategory): string { @@ -680,6 +683,11 @@ function classifyPayablesLiabilityCategory(row: ComposeStageRow, counterparty: s }; const reasons = new Set(); const text = `${counterparty} ${row.registrator} ${row.analytics.join(" ")}`.toLowerCase(); + const hasBankOrCreditSignal = /(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit|bank)/iu.test(text); + const hasTaxOrStateSignal = + /(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал|налогов)/iu.test(text); + const hasCommercialCounterpartySignal = + /(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor|заказчик|клиент|counterparty)/iu.test(text); const accountPrefixes = [extractAccountSectionCode(row.account_dt), extractAccountSectionCode(row.account_kt)].filter( (item): item is string => Boolean(item) @@ -697,19 +705,21 @@ function classifyPayablesLiabilityCategory(row: ComposeStageRow, counterparty: s reasons.add("участие счета 68/69"); } if (accountPrefixes.includes("76")) { - scores.supplier_or_contractor += 1; - reasons.add("участие счета 76"); + scores.other += 1; + reasons.add("участие счета 76 (прочие расчеты)"); } - if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) { - scores.bank_or_credit += 3; + if (hasBankOrCreditSignal) { + scores.bank_or_credit += 6; + scores.supplier_or_contractor = Math.max(0, scores.supplier_or_contractor - 2); reasons.add("банк/кредит в аналитике"); } - if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) { - scores.tax_or_state += 3; + if (hasTaxOrStateSignal) { + scores.tax_or_state += 6; + scores.supplier_or_contractor = Math.max(0, scores.supplier_or_contractor - 2); reasons.add("налог/госорган в аналитике"); } - if (/(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor)/iu.test(text)) { + if (hasCommercialCounterpartySignal && !hasBankOrCreditSignal && !hasTaxOrStateSignal) { scores.supplier_or_contractor += 2; reasons.add("коммерческий контрагент в аналитике"); } @@ -852,6 +862,9 @@ function buildPayablesConfirmedBalanceAggregate( lastPeriod: string | null; categoryScores: Record; reasons: Set; + contracts: Set; + documents: Set; + sourceRefs: Set; } >(); const asOfTimestamp = toUtcDayTimestamp(asOfDate); @@ -882,6 +895,8 @@ function buildPayablesConfirmedBalanceAggregate( } const classified = classifyPayablesLiabilityCategory(row, name); + const contract = extractContractName(row); + const sourceRefs = extractPayablesSourceRefs(row, name, contract); const current = byCounterparty.get(name); if (!current) { byCounterparty.set(name, { @@ -895,7 +910,10 @@ function buildPayablesConfirmedBalanceAggregate( tax_or_state: classified.scores.tax_or_state, other: classified.scores.other }, - reasons: new Set(classified.reasons) + reasons: new Set(classified.reasons), + contracts: new Set(contract ? [contract] : []), + documents: new Set(row.registrator ? [row.registrator] : []), + sourceRefs: new Set(sourceRefs) }); continue; } @@ -915,6 +933,15 @@ function buildPayablesConfirmedBalanceAggregate( for (const reason of classified.reasons) { current.reasons.add(reason); } + if (contract) { + current.contracts.add(contract); + } + if (row.registrator) { + current.documents.add(row.registrator); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } } return Array.from(byCounterparty.entries()) @@ -925,7 +952,10 @@ function buildPayablesConfirmedBalanceAggregate( firstPeriod: item.firstPeriod, lastPeriod: item.lastPeriod, category: resolvePayablesLiabilityCategory(item.categoryScores), - categoryReasons: Array.from(item.reasons).slice(0, 2) + categoryReasons: Array.from(item.reasons).slice(0, 2), + contracts: Array.from(item.contracts).slice(0, 2), + documents: Array.from(item.documents).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) })) .filter((item) => item.outstandingAmount > 0.005) .sort((left, right) => { @@ -1142,6 +1172,69 @@ function extractContractName(row: ComposeStageRow): string | null { return null; } +function normalizeEntityToken(value: string | null | undefined): string { + return String(value ?? "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/\s+/g, " ") + .trim(); +} + +function extractPayablesSourceRefs( + row: ComposeStageRow, + counterparty: string, + contract: string | null +): string[] { + const refs = new Set(); + const counterpartyToken = normalizeEntityToken(counterparty); + const contractToken = normalizeEntityToken(contract); + const sourceRefTokenPattern = + /(?:№|договор|contract|счет|сч[её]т|акт|накладн|плат[её]ж|invoice|payment|order|заявк|реестр|доп\.?\s*соглаш)/iu; + + for (const token of row.analytics) { + const normalized = String(token ?? "").trim(); + if (!normalized) { + continue; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(normalized)) { + continue; + } + if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) { + continue; + } + const tokenNormalized = normalizeEntityToken(normalized); + if (!tokenNormalized) { + continue; + } + if (tokenNormalized === counterpartyToken || (contractToken && tokenNormalized === contractToken)) { + continue; + } + if (sourceRefTokenPattern.test(normalized) || /(?:[a-zа-я].*\d|\d.*[a-zа-я])/iu.test(normalized)) { + refs.add(normalized); + } + if (refs.size >= 3) { + break; + } + } + + return Array.from(refs); +} + +function formatPayablesEvidenceSuffix(item: PayablesConfirmedBalanceAggregate): string { + const parts: string[] = []; + if (item.contracts.length > 0) { + parts.push(`договор: ${item.contracts.slice(0, 2).join("; ")}`); + } + if (item.documents.length > 0) { + const suffix = item.documents.length > 1 ? ` (+${item.documents.length - 1})` : ""; + parts.push(`документ: ${item.documents[0]}${suffix}`); + } + if (item.sourceRefs.length > 0) { + parts.push(`source refs: ${item.sourceRefs.slice(0, 2).join("; ")}`); + } + return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; +} + function deriveOperationalYearWindow( yearDocs: YearAggPoint[], yearOps: YearAggPoint[] @@ -2319,9 +2412,8 @@ export function composeFactualReply( const asOfDate = normalizeIsoDateOnly(options.asOfDate); const periodFrom = normalizeIsoDateOnly(options.periodFrom); const periodTo = normalizeIsoDateOnly(options.periodTo); - const scopeLine = asOfDate - ? `- Дата среза: ${formatDateRu(asOfDate)}.` - : periodFrom || periodTo + const periodScopeLine = + !asOfDate && (periodFrom || periodTo) ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` : null; const carryoverLine = @@ -2345,8 +2437,8 @@ export function composeFactualReply( lines.push(""); lines.push("Блок 2. Что учтено"); lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`); - if (scopeLine) { - lines.push(scopeLine); + if (periodScopeLine) { + lines.push(periodScopeLine); } lines.push("- Контур: обязательства по счетам 60/76."); if (carryoverLine) { @@ -2371,7 +2463,7 @@ export function composeFactualReply( lines.push( ...confirmedBalances.slice(0, 10).map( (item, index) => - `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}` + `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}` ) ); } else { @@ -2516,10 +2608,10 @@ export function composeFactualReply( `- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`, `- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`, "", - "Блок 5. Кому нужно заплатить в первую очередь (по сумме остатка):", + "Блок 5. Крупнейшие подтвержденные позиции к оплате (по сумме остатка):", ...confirmedBalances.slice(0, 10).map( (item, index) => - `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}` + `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}` ) ]; return { diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 9bc8aa8..9dde126 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3750,6 +3750,28 @@ export function resolveAssistantOrchestrationDecision(input) { hasAccountingSignal(repairedEffectiveAddressUserMessage) || hasDataRetrievalRequestSignal(rawUserMessage) || hasDataRetrievalRequestSignal(repairedRawUserMessage); + const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); + const llmFirstAddressCandidate = Boolean(llmContractMode === "address_query" && llmContractIntent && llmContractIntent !== "unknown"); + const llmFirstUnsupportedCandidate = Boolean(llmContractMode === "unsupported" && + (!llmContractIntent || llmContractIntent === "unknown")); + const dangerOrCoercionSignal = hasDangerOrCoercionSignal(rawUserMessage) || + hasDangerOrCoercionSignal(repairedRawUserMessage) || + hasDangerOrCoercionSignal(effectiveAddressUserMessage) || + hasDangerOrCoercionSignal(repairedEffectiveAddressUserMessage); + const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) || + hasAddressFollowupContextSignal(repairedRawUserMessage) || + hasAddressFollowupContextSignal(effectiveAddressUserMessage) || + hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage); + const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; + const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && + !capabilityMetaQuery && + !dataRetrievalSignal && + !effectiveAddressFollowupSignal && + modeDetection.mode === "unsupported" && + intentResolution.intent === "unknown"); + const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && + deterministicNonDomainGuard && + (llmFirstUnsupportedCandidate || llmContractMode === null)); const hardMetaMode = dataScopeMetaQuery ? "data_scope" : capabilityMetaQuery && !dataRetrievalSignal @@ -3811,8 +3833,35 @@ export function resolveAssistantOrchestrationDecision(input) { } }; } + if (nonDomainQueryIndexed) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "non_domain_query_indexed", + livingMode: "chat", + livingReason: "non_domain_query_indexed", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: "non_domain", + address_mode: modeDetection.mode, + address_mode_confidence: modeDetection.confidence, + address_intent: intentResolution.intent, + address_intent_confidence: intentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + followup_context_detected: Boolean(followupContext), + unsupported_address_intent_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "non_domain_query_indexed", + living_mode: "chat", + living_reason: "non_domain_query_indexed" + } + } + }; + } const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); - const llmContractMode = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.mode); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -4118,11 +4167,11 @@ function hasOperationalAdminActionRequestSignal(text) { return /(?:удаляй?\s+баз|удали\s+баз|снеси\s+баз|delete\s+database|drop\s+database)/i.test(normalized); } function hasDangerOrCoercionSignal(text) { - const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е"); - if (!lower) { + const normalized = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е"); + if (!normalized) { return false; } - return /(?:убьют|убьют|убью|убить|убиют|угрож|опасн|омон|полици|насили|заставля|принужда|шантаж)/i.test(lower); + return /(?:убью|убить|убьют|убиют|угрож|опасн|омон|полици|насили|заставля|принужда|шантаж|выпил(?:юсь|иться|ился|илась|иться)|суицид|самоубий|покончу\s+с\s+собой|не\s+хочу\s+жить|хочу\s+умереть|сдохнуть|вскрыть\s+вены|повешусь|повеситься|спрыгну|kill\s+myself|end\s+my\s+life|suicid|self[\s-]?harm)/iu.test(normalized); } function hasDestructiveDataActionSignal(text) { const lower = compactWhitespace(String(text ?? "").toLowerCase()).replace(/ё/g, "е"); @@ -4240,6 +4289,9 @@ function hasLivingChatSignal(text) { if (!lower) { return false; } + if (/^(?:а\s+)?(?:тут|здесь|там|сюда|туда)[\s!?.,:;\-]*$/iu.test(lower)) { + return true; + } if (/^(ага|угу|ок|окей|ясно|понял|поняла|принято|спасибо|благодарю|супер|класс|норм|го|давай|погнали|привет|хай|йо|yo|че\s+там|ч[её]\s+как|че\s+как|hello|hi|thanks?)$/i.test(lower)) { return true; } diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 9c1f056..b9e075c 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -70,6 +70,18 @@ describe("assistant living router mode decision", () => { expect(decision.mode).toBe("deep_analysis"); expect(decision.reason).toBe("predecompose_unsupported_mode_fallback_to_deep"); }); + + it("routes ultra-short deictic follow-up ('тут?') to chat mode", () => { + const decision = resolveLivingAssistantModeDecision({ + userMessage: "тут?", + addressLaneTriggered: false, + useMock: false, + predecomposeMode: "unsupported", + predecomposeModeConfidence: "low" + }); + expect(decision.mode).toBe("chat"); + expect(decision.reason).toBe("living_chat_signal_detected"); + }); it("routes capability question to chat even when phrase contains 1С", () => { const decision = resolveLivingAssistantModeDecision({ userMessage: "и 1с можешь настроить?", @@ -186,6 +198,39 @@ describe("assistant living router mode decision", () => { }); describe("assistant orchestration contract", () => { + it("routes non-domain emotional follow-up to indexed chat path instead of address lane", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: "бля хочу выпилиться от этого ебаного 1с", + effectiveAddressUserMessage: "бля хочу выпилиться от этого ебаного 1с", + followupContext: { + previous_intent: "payables_confirmed_as_of_date", + previous_filters: { + period_from: "2021-05-01", + period_to: "2021-05-31", + as_of_date: "2021-05-31" + } + }, + llmPreDecomposeMeta: { + applied: false, + reason: "no_usable_fragment", + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + } + } as any, + useMock: false + }); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("non_domain_query_indexed"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("non_domain_query_indexed"); + expect(decision.orchestrationContract?.hard_meta_mode).toBe("non_domain"); + }); + it("keeps VAT payable forecast query in address lane", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "какой прогноз оплаты ндс за 12 мая 2020",