diff --git a/docs/orchestration/hm_business_profile_dependency_mini_pack_20260526.json b/docs/orchestration/hm_business_profile_dependency_mini_pack_20260526.json new file mode 100644 index 0000000..08ce06f --- /dev/null +++ b/docs/orchestration/hm_business_profile_dependency_mini_pack_20260526.json @@ -0,0 +1,186 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "hm_business_profile_dependency_mini_pack_20260526", + "domain": "assistant_stage1_business_overview", + "title": "Hm business profile dependency mini pack", + "description": "Mini semantic replay for broad HM business profile: company evaluation, profit boundary, customer/supplier dependency signals, and next checks must stay business-useful, evidence-bounded, and free of internal MCP mechanics.", + "bindings": {}, + "steps": [ + { + "step_id": "s01_establish_company_top_year", + "title": "Establish company context with a top-year question", + "question": "какой у ООО \"Альтернатива Плюс\" самый доходный год", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "2015", + "136 723 459,73", + "не полный бухгалтерский рейтинг" + ], + "forbidden_answer_patterns": [ + "Коротко:", + "capability_id", + "mcp_discovery", + "query_movements" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "company_context", + "yearly_ranking" + ] + }, + { + "step_id": "s02_broad_company_evaluation", + "title": "Broad company evaluation keeps the evidence limit in the first layer", + "question": "Как ты оценишь деятельность компании?", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "ограниченн", + "не аудиторское", + "не подтвержден", + "чист", + "прибыл", + "Комитет государственных услуг", + "зависим" + ], + "forbidden_answer_patterns": [ + "Коротко:", + "capability_id", + "mcp_discovery", + "query_movements", + "business_overview_route_template_v1" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "broad_business_evaluation", + "coverage_honesty", + "profit_boundary" + ] + }, + { + "step_id": "s03_profitability_boundary", + "title": "Profitability follow-up does not convert cashflow net into profit", + "question": "то есть бизнес прибыльный или это только обороты?", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "не подтверждаю", + "чист", + "прибыл", + "денежн", + "нетто", + "не полный отчет" + ], + "forbidden_answer_patterns": [ + "Коротко:", + "capability_id", + "mcp_discovery", + "это прибыль" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "profitability_boundary", + "cashflow_not_profit" + ] + }, + { + "step_id": "s04_customer_dependency_signal", + "title": "Customer dependency signal stays bounded to incoming money evidence", + "question": "от кого мы больше всего зависим по входящим деньгам?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "Комитет государственных услуг", + "133.839.880,82", + "концентрац", + "входящ", + "не чистая прибыль" + ], + "forbidden_answer_patterns": [ + "Коротко:", + "capability_id", + "mcp_discovery", + "query_movements" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "customer_dependency", + "incoming_counterparty_concentration" + ] + }, + { + "step_id": "s05_supplier_dependency_signal", + "title": "Supplier dependency signal stays bounded to outgoing money evidence", + "question": "а по поставщикам и исходящим платежам от кого зависимость?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "ГТК-Интер", + "44 943 460", + "32.58", + "исходящ", + "не аудит" + ], + "forbidden_answer_patterns": [ + "Коротко:", + "capability_id", + "mcp_discovery", + "query_movements" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "supplier_dependency", + "outgoing_counterparty_concentration" + ] + }, + { + "step_id": "s06_next_checks", + "title": "Next-check answer gives concrete analyst path instead of vague advice", + "question": "что дальше проверять в первую очередь?", + "allowed_reply_types": [ + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "чист", + "прибыл", + "90/91/99", + "топ-3", + "склад", + "долг" + ], + "forbidden_answer_patterns": [ + "Коротко:", + "capability_id", + "mcp_discovery", + "query_movements", + "business_overview_route_template_v1" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "next_checks", + "analyst_action_plan" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index f6c8df8..2e5a029 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1869,6 +1869,10 @@ function resolveUnicodeAddressIntentBridge(text) { /(?:хвост|задержк|проблем|систематическ)/iu.test(normalized)) { return unicodeBridgeResolution("list_payables_counterparties", "high", "supplier_tail_risk_signal_detected"); } + if (/(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) && + /(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalized)) { + return unicodeBridgeResolution("unknown", "high", "unicode_business_overview_supplier_quality_deferred_to_discovery"); + } if (hasDocumentCue && (hasCounterpartyCue || hasLooseCounterpartyByAnchor) && !hasContractCue) { return unicodeBridgeResolution("list_documents_by_counterparty", "high", "unicode_documents_by_counterparty_bridge_signal_detected"); } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 7482df6..ddb9234 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -3202,8 +3202,14 @@ class AddressQueryService { shadowRouteAudit }); } + const rawUserMessageForReply = typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0 + ? options.rawUserMessage + : undefined; const composeOptionsFromFilters = (filterSet, options = {}) => ({ userMessage, + rawUserMessage: typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0 + ? options.rawUserMessage + : rawUserMessageForReply, itemHint: typeof filterSet.item === "string" ? filterSet.item : undefined, counterpartyHint: typeof options.counterpartyHint === "string" ? options.counterpartyHint diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js index 1d7256b..a7e4a72 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -406,7 +406,11 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { const focus = deps.detectValueRankingFocus(options.userMessage); const limit = deps.detectRankingLimit(options.userMessage, 20); const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage); - const normalizedQuestion = deps.normalizeQuestionText(options.userMessage); + const questionSignalText = [options.rawUserMessage, options.userMessage] + .map((value) => String(value ?? "").trim()) + .filter(Boolean) + .join(" "); + const normalizedQuestion = deps.normalizeQuestionText(questionSignalText || options.userMessage); const asksSingleBestCounterparty = focus === "top_by_total" && /(?:какой|кто|which|who|какой|кто)/iu.test(normalizedQuestion) && /(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|прин[её]с|highest|most|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибол|РїСЂРёРЅ[её]СЃ)/iu.test(normalizedQuestion) && @@ -418,6 +422,7 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit; const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier); const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion); + const dependencyCue = /(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalizedQuestion); const byCounterparty = new Map(); const byYear = new Map(); const deals = []; @@ -653,13 +658,20 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { ? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` : `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` : isSupplier - ? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` - : `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`; + ? dependencyCue + ? `Главный сигнал зависимости по исходящим платежам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : dependencyCue + ? `Главный сигнал зависимости по входящим деньгам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`; lines.unshift(directAnswerLine); + if (dependencyCue) { + lines.splice(1, 0, "Это сигнал концентрации по найденным денежным строкам 1С, а не подтверждение договорной зависимости, надежности контрагента или чистой прибыли."); + } if (!isSupplier) { - lines.splice(1, 0, cashflowBoundaryLine); + lines.splice(dependencyCue ? 2 : 1, 0, cashflowBoundaryLine); if (cashflowNextStepLine) { - lines.splice(2, 0, cashflowNextStepLine); + lines.splice(dependencyCue ? 3 : 2, 0, cashflowNextStepLine); } } } diff --git a/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js index 5958f1f..c512e39 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressAttemptRuntimeAdapter.js @@ -98,6 +98,7 @@ async function runAssistantAddressAttemptRuntime(input) { }; const runAddressLaneAttempt = async (messageUsed, carryMeta, analysisDateHint, llmSemanticHints = null) => runAddressLaneAttemptRuntimeSafe((0, assistantAddressLaneAttemptInputBuilder_1.buildAssistantAddressLaneAttemptRuntimeInput)({ messageUsed, + rawUserMessage: input.userMessage, carryMeta, analysisDateHint, llmSemanticHints, diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptInputBuilder.js b/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptInputBuilder.js index de3c334..3ef0185 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptInputBuilder.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptInputBuilder.js @@ -4,6 +4,7 @@ exports.buildAssistantAddressLaneAttemptRuntimeInput = buildAssistantAddressLane function buildAssistantAddressLaneAttemptRuntimeInput(input) { return { messageUsed: input.messageUsed, + rawUserMessage: input.rawUserMessage, carryMeta: input.carryMeta, analysisDateHint: input.analysisDateHint, llmSemanticHints: input.llmSemanticHints, diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptQueryOptionsBuilder.js b/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptQueryOptionsBuilder.js index 0c224c8..4a43067 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptQueryOptionsBuilder.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptQueryOptionsBuilder.js @@ -9,7 +9,8 @@ function resolveAssistantAddressLaneAttemptFollowupContext(carryMeta) { } function buildAssistantAddressLaneAttemptQueryOptions(input) { const base = { - analysisDateHint: input.analysisDateHint + analysisDateHint: input.analysisDateHint, + rawUserMessage: input.rawUserMessage ?? undefined }; if (input.scopedFollowupContext) { base.followupContext = input.scopedFollowupContext; diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptRuntimeAdapter.js index cfd96be..8f31613 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneAttemptRuntimeAdapter.js @@ -10,6 +10,7 @@ async function runAssistantAddressLaneAttemptRuntime(input) { scopedFollowupContext, llmSemanticHints: input.llmSemanticHints ?? null, activeOrganization: input.activeOrganization, - knownOrganizations: input.knownOrganizations + knownOrganizations: input.knownOrganizations, + rawUserMessage: input.rawUserMessage ?? input.messageUsed })); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index f0e1442..52cfd88 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -69,6 +69,10 @@ function requestsCounterpartyLeaderSurface(turnMeaning, graph) { } return /(?:(?:кто|как\p{L}*|покаж\p{L}*|назов\p{L}*|раскро\p{L}*)[\s\S]{0,100}(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*|топ)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:топ[-\s]*(?:клиент|заказчик|поставщик|получател))|(?:клиент|поставщик)[\s\S]{0,80}(?:главн|крупнейш|основн|ведущ|топ))/iu.test(text); } +function requestsBusinessOverviewNextChecks(turnMeaning, graph) { + const text = normalizedTurnAndGraphText(turnMeaning, graph); + return /(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test(text); +} function requestsCompactCashflowAnswer(turnMeaning, graph) { const text = normalizeQuestionText([ turnMeaning?.raw_message, @@ -429,6 +433,13 @@ function businessOverviewPeriodText(overview) { const period = toNonEmptyString(overview.period_scope); return period ? `за ${period}` : "за все доступное проверенное окно"; } +function businessOverviewPeriodPhrase(value) { + const normalized = toNonEmptyString(value); + if (!normalized) { + return "за все доступное проверенное окно"; + } + return /^за\s+/iu.test(normalized) ? normalized : `за ${normalized}`; +} function strongestIncomingYear(overview) { const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; const sorted = years @@ -857,6 +868,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { const compactCashflowRequested = directMoneyOnlyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph); const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph); const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph); + const nextChecksRequested = requestsBusinessOverviewNextChecks(turnMeaning, graph); const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? toNonEmptyString(turnMeaning?.effective_message); const earningsAmountRequested = /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0434\u043e\u0445\u043e\u0434\p{L}*)/iu.test(normalizeQuestionText(rawMessage ?? "")); const accountingFinancialResultForEarnings = toRecordObject(overview.accounting_financial_result); @@ -1039,9 +1051,12 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { ? `${vendorProcurementQuality.top_non_financial_supplier_share_pct}%` : null; const periodScope = toNonEmptyString(vendorProcurementQuality.period_scope) ?? period; - const totalText = totalOutgoing ? `; всего исходящих платежей в проверенном срезе ${totalOutgoing}` : ""; + const periodPhrase = businessOverviewPeriodPhrase(periodScope); + const totalText = totalOutgoing + ? `; всего исходящих платежей в проверенном срезе ${sentenceAmount(totalOutgoing)}` + : ""; if (status === "financial_institution_leads_outgoing_cash") { - lines.push(`Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`); + lines.push(`Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.`); const financialHintText = financialFlowHintTextRuFromRecord(topOutgoingRecord); if (financialHintText) { lines.push(financialHintText); @@ -1051,17 +1066,17 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { } } else if (status === "reviewed_procurement_concentration") { - lines.push(`Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей за ${periodScope} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.`); + lines.push(`Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей ${periodPhrase} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.`); } else { - lines.push(`Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.`); + lines.push(`Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.`); } const contractText = typeof vendorProcurementQuality.used_contracts === "number" && Number.isFinite(vendorProcurementQuality.used_contracts) ? typeof vendorProcurementQuality.total_contracts === "number" && Number.isFinite(vendorProcurementQuality.total_contracts) ? ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts}/${vendorProcurementQuality.total_contracts} договоров${typeof vendorProcurementQuality.used_contract_share_pct === "number" && Number.isFinite(vendorProcurementQuality.used_contract_share_pct) ? ` (${vendorProcurementQuality.used_contract_share_pct}%)` : ""}.` : ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts} договоров.` : ""; - lines.push(`Что не доказано этим срезом: надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полная структура всех расходов.${contractText}`); + lines.push(`Это не аудит поставщиков: срез не доказывает надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полную структуру всех расходов.${contractText}`); return joinBusinessReplyLines(lines); } const supplierBasis = topSupplier @@ -1102,6 +1117,19 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { lines.push("Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; косвенные признаки нельзя выдавать за доказанный факт резерва."); return joinBusinessReplyLines(lines); } + if (!separateSubject && + !crossScopeExecutiveSummary && + !counterpartyLeaderSurfaceRequested && + !rankingNeed && + nextChecksRequested) { + const subject = organizationScope ? `по компании ${organizationScope}` : "по компании"; + lines.push(`В первую очередь ${subject} надо проверить чистую прибыль через закрытие 90/91/99, зависимость от топ-3 клиентов и поставщиков, долги/зависшие расчеты, складские остатки и НДС-позицию.`); + lines.push("Смысл порядка: сначала отделить бухгалтерский финрезультат от денежного нетто, затем проверить концентрацию потоков и только после этого углубляться в склад, долги и налоговую позицию."); + if (limitLine) { + lines.push(limitLine); + } + return joinBusinessReplyLines(lines); + } if (!separateSubject && !crossScopeExecutiveSummary && !counterpartyLeaderSurfaceRequested && diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 0f7dbb2..5b1b712 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -676,6 +676,15 @@ function hasDebtDueDateBoundaryFollowupSignal(text) { const hasFollowupShape = /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\w*|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|why|short|brief|so)/iu.test(text); return hasDueDateCue && hasFollowupShape; } +function hasSupplierDependencyFollowupSignal(text) { + if (!text) { + return false; + } + const normalized = compactLower(text); + const hasSupplierOrOutgoingCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0437\u0430\u043a\u0443\u043f|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436|\u0437\u0430\u043f\u043b\u0430\u0442|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized); + const hasDependencyCue = /(?:\u0437\u0430\u0432\u0438\u0441\u0438\u043c|\u0437\u0430\u0432\u044f\u0437\u0430\u043d|\u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446|\u043a\u043b\u044e\u0447\u0435\u0432|\u043e\u0441\u043d\u043e\u0432\u043d|\u043a\u0440\u0438\u0442\u0438\u0447|dependency|concentration|key|critical)/iu.test(normalized); + return hasSupplierOrOutgoingCue && hasDependencyCue; +} function hasOrganizationLevelDebtDueDateOverviewSignal(text) { if (!text) { return false; @@ -771,7 +780,7 @@ function hasBusinessOverviewContinuationSignal(text) { } const normalized = compactLower(text); const hasEvidenceContinuationCue = /(?:по\s+этим\s+данн|на\s+базе\s+этих\s+данн|из\s+этого|по\s+итогам|что\s+подтвержден|чего\s+не\s+хватает|что\s+пока\s+нельзя|что\s+можно\s+сказать|какой\s+вывод|итогов|резюм|вывод)/iu.test(normalized); - const hasAnalystContinuationCue = /(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|налогов\p{L}*\s+вывод)/iu.test(normalized); + const hasAnalystContinuationCue = /(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|зависим|завязан|концентрац|налогов\p{L}*\s+вывод)/iu.test(normalized); const hasTaxContinuationCue = /(?:ндс|vat)[\s\S]{0,120}(?:позици|период|основан|не\s+хватает|налогов\p{L}*\s+вывод)|(?:позици|налогов\p{L}*\s+вывод)[\s\S]{0,80}(?:ндс|vat)/iu.test(normalized); const hasFinalSummaryCue = /(?:\u0447\u0442\u043e\s+\u043c\u044b\s+\u0437\u043d\u0430\u0435\u043c|\u0447\u0442\u043e\s+\u043f\u043e\u043d\u044f\u0442\u043d\u043e|\u0447\u0442\u043e\s+\u043f\u0440\u043e\u0432\u0435\u0440\w*\s+\u0434\u0430\u043b\u044c\u0448\u0435|\u0441\u043b\u0435\u0434\u0443\u044e\u0449\w*\s+\u0448\u0430\u0433|\u0438\u0442\u043e\u0433\w*\s+\u0432\u044b\u0432\u043e\u0434|\u043a\u0430\u043a\u043e\u0439\s+\u0432\u044b\u0432\u043e\u0434|\u0447\u0442\u043e\s+\u0441\s+\u044d\u0442\u0438\u043c\s+\u0434\u0435\u043b\u0430\u0442\u044c|what\s+do\s+we\s+know|what\s+is\s+missing|next\s+step|final\s+summary)/iu.test(normalized); const hasMoneyBreakdownCue = /(?:\u0440\u0430\u0441\u043a\u0440\u043e\p{L}*\s+\u0434\u0435\u043d\p{L}*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u0432\u0441\u0435\u0433\u043e\s+\u043f\u043e\u043b\u0443\u0447|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u0432\u0441\u0435\u0433\u043e\s+)?\u0437\u0430\u043f\u043b\u0430\u0442|\u0447\u0438\u0441\u0442\p{L}*\s+\u0434\u0435\u043d\u0435\u0436\u043d\p{L}*\s+\u043f\u043e\u0442\u043e\u043a|\u0433\u043b\u0430\u0432\u043d\p{L}*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a)|top\s+(?:customer|supplier)|cash\s+breakdown)/iu.test(normalized) && @@ -1339,7 +1348,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const debtDueDateBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) || debtDueDateFollowupBusinessOverviewSignal; - const supplierQualityBusinessOverviewSignal = (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText); + const supplierQualityBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + (hasOrganizationLevelSupplierQualityOverviewSignal(rawText) || hasSupplierDependencyFollowupSignal(rawText))) || + (businessOverviewContinuationSignal && hasSupplierDependencyFollowupSignal(rawText)); const profitMarginFollowupBusinessOverviewSignal = businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText); const profitMarginBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelProfitMarginBoundaryOverviewSignal(rawText)) || @@ -2389,6 +2400,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (debtDueDateFollowupBusinessOverviewSignal) { pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary"); } + if (supplierQualityBusinessOverviewSignal && hasSupplierDependencyFollowupSignal(rawText)) { + pushReason(reasonCodes, "mcp_discovery_business_overview_supplier_dependency_followup_boundary"); + } if (explicitVatSuppressesBusinessOverviewContinuation) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"); } diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index e712f98..a115bcb 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -309,6 +309,17 @@ function detectBroadBusinessEvaluation(text) { family: "broad_business_evaluation" }; } + if (/(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test(normalized)) { + return { + family: "broad_business_evaluation" + }; + } + if (/(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) && + /(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalized)) { + return { + family: "broad_business_evaluation" + }; + } if (hasOrganizationLevelEarningsOverviewSignal(normalized)) { return { family: "broad_business_evaluation" diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index c0914d3..1d1da4c 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2558,6 +2558,17 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio ); } + if ( + /(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) && + /(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test(normalized) + ) { + return unicodeBridgeResolution( + "unknown", + "high", + "unicode_business_overview_supplier_quality_deferred_to_discovery" + ); + } + if (hasDocumentCue && (hasCounterpartyCue || hasLooseCounterpartyByAnchor) && !hasContractCue) { return unicodeBridgeResolution( "list_documents_by_counterparty", diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 450db90..e3336ff 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -99,6 +99,7 @@ interface AddressTryHandleOptions { llmSemanticHints?: AddressLlmSemanticHints | null; activeOrganization?: string | null; knownOrganizations?: string[]; + rawUserMessage?: string | null; } interface AddressCapabilityAudit { @@ -3943,6 +3944,10 @@ export class AddressQueryService { shadowRouteAudit }); } + const rawUserMessageForReply = + typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0 + ? options.rawUserMessage + : undefined; const composeOptionsFromFilters = ( filterSet: AddressFilterSet, options: { @@ -3951,9 +3956,14 @@ export class AddressQueryService { useRubCurrency?: boolean; counterpartyHint?: string; accountHint?: string; + rawUserMessage?: string | null; } = {} ) => ({ userMessage, + rawUserMessage: + typeof options.rawUserMessage === "string" && options.rawUserMessage.trim().length > 0 + ? options.rawUserMessage + : rawUserMessageForReply, itemHint: typeof filterSet.item === "string" ? filterSet.item : undefined, counterpartyHint: typeof options.counterpartyHint === "string" diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts index 2c133cd..2cc7766 100644 --- a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -548,7 +548,11 @@ export function composeCounterpartyAnalyticsReply( const focus = deps.detectValueRankingFocus(options.userMessage); const limit = deps.detectRankingLimit(options.userMessage, 20); const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage); - const normalizedQuestion = deps.normalizeQuestionText(options.userMessage); + const questionSignalText = [options.rawUserMessage, options.userMessage] + .map((value) => String(value ?? "").trim()) + .filter(Boolean) + .join(" "); + const normalizedQuestion = deps.normalizeQuestionText(questionSignalText || options.userMessage); const asksSingleBestCounterparty = focus === "top_by_total" && /(?:какой|кто|which|who|какой|кто)/iu.test(normalizedQuestion) && @@ -572,6 +576,10 @@ export function composeCounterpartyAnalyticsReply( const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit; const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier); const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion); + const dependencyCue = + /(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test( + normalizedQuestion + ); const byCounterparty = new Map(); const byYear = new Map(); @@ -864,13 +872,24 @@ export function composeCounterpartyAnalyticsReply( ? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` : `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` : isSupplier - ? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` - : `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`; + ? dependencyCue + ? `Главный сигнал зависимости по исходящим платежам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : dependencyCue + ? `Главный сигнал зависимости по входящим деньгам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`; lines.unshift(directAnswerLine); + if (dependencyCue) { + lines.splice( + 1, + 0, + "Это сигнал концентрации по найденным денежным строкам 1С, а не подтверждение договорной зависимости, надежности контрагента или чистой прибыли." + ); + } if (!isSupplier) { - lines.splice(1, 0, cashflowBoundaryLine); + lines.splice(dependencyCue ? 2 : 1, 0, cashflowBoundaryLine); if (cashflowNextStepLine) { - lines.splice(2, 0, cashflowNextStepLine); + lines.splice(dependencyCue ? 3 : 2, 0, cashflowNextStepLine); } } } diff --git a/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts b/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts index 6a37fb3..12d30a9 100644 --- a/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts +++ b/llm_normalizer/backend/src/services/address_runtime/replyPackaging.ts @@ -2,6 +2,7 @@ import type { AddressEvidenceStrength, AddressResponseType, AddressResultMode } export interface ComposeFactualReplyOptions { userMessage?: string; + rawUserMessage?: string; itemHint?: string; counterpartyHint?: string; organizationHint?: string; diff --git a/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts index 01b52e5..e99b986 100644 --- a/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressAttemptRuntimeAdapter.ts @@ -224,6 +224,7 @@ export async function runAssistantAddressAttemptRuntime( runAddressLaneAttemptRuntimeSafe( buildAssistantAddressLaneAttemptRuntimeInput({ messageUsed, + rawUserMessage: input.userMessage, carryMeta, analysisDateHint, llmSemanticHints, diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneAttemptInputBuilder.ts b/llm_normalizer/backend/src/services/assistantAddressLaneAttemptInputBuilder.ts index 72dc45a..b7640b7 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneAttemptInputBuilder.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneAttemptInputBuilder.ts @@ -2,6 +2,7 @@ import type { RunAssistantAddressLaneAttemptRuntimeInput } from "./assistantAddr export interface BuildAssistantAddressLaneAttemptRuntimeInputInput { messageUsed: RunAssistantAddressLaneAttemptRuntimeInput["messageUsed"]; + rawUserMessage: RunAssistantAddressLaneAttemptRuntimeInput["rawUserMessage"]; carryMeta: RunAssistantAddressLaneAttemptRuntimeInput["carryMeta"]; analysisDateHint: RunAssistantAddressLaneAttemptRuntimeInput["analysisDateHint"]; llmSemanticHints: RunAssistantAddressLaneAttemptRuntimeInput["llmSemanticHints"]; @@ -17,6 +18,7 @@ export function buildAssistantAddressLaneAttemptRuntimeInput( ): RunAssistantAddressLaneAttemptRuntimeInput { return { messageUsed: input.messageUsed, + rawUserMessage: input.rawUserMessage, carryMeta: input.carryMeta, analysisDateHint: input.analysisDateHint, llmSemanticHints: input.llmSemanticHints, diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneAttemptQueryOptionsBuilder.ts b/llm_normalizer/backend/src/services/assistantAddressLaneAttemptQueryOptionsBuilder.ts index adbcc5f..4218811 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneAttemptQueryOptionsBuilder.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneAttemptQueryOptionsBuilder.ts @@ -10,6 +10,7 @@ export function resolveAssistantAddressLaneAttemptFollowupContext( export interface BuildAssistantAddressLaneAttemptQueryOptionsInput { analysisDateHint: RunAssistantAddressLaneAttemptRuntimeInput["analysisDateHint"]; + rawUserMessage: RunAssistantAddressLaneAttemptRuntimeInput["rawUserMessage"]; scopedFollowupContext: Record | null; llmSemanticHints: RunAssistantAddressLaneAttemptRuntimeInput["llmSemanticHints"]; activeOrganization: RunAssistantAddressLaneAttemptRuntimeInput["activeOrganization"]; @@ -20,7 +21,8 @@ export function buildAssistantAddressLaneAttemptQueryOptions( input: BuildAssistantAddressLaneAttemptQueryOptionsInput ): Parameters[1] { const base = { - analysisDateHint: input.analysisDateHint + analysisDateHint: input.analysisDateHint, + rawUserMessage: input.rawUserMessage ?? undefined } as Parameters[1]; if (input.scopedFollowupContext) { base.followupContext = input.scopedFollowupContext; diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneAttemptRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneAttemptRuntimeAdapter.ts index 4ced9a8..04ec8bc 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneAttemptRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneAttemptRuntimeAdapter.ts @@ -7,6 +7,7 @@ import { export interface RunAssistantAddressLaneAttemptRuntimeInput { messageUsed: string; + rawUserMessage?: string | null; carryMeta: AssistantAddressCarryoverLike | null; analysisDateHint: string | null; llmSemanticHints?: Record | null; @@ -24,6 +25,7 @@ export interface RunAssistantAddressLaneAttemptRuntimeInput { llmSemanticHints?: Record | null; activeOrganization?: string | null; knownOrganizations?: string[]; + rawUserMessage?: string | null; } ) => Promise; } @@ -43,7 +45,8 @@ export async function runAssistantAddressLaneAttemptRuntime( scopedFollowupContext, llmSemanticHints: input.llmSemanticHints ?? null, activeOrganization: input.activeOrganization, - knownOrganizations: input.knownOrganizations + knownOrganizations: input.knownOrganizations, + rawUserMessage: input.rawUserMessage ?? input.messageUsed }) ); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index f2aeb00..3d6539e 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -107,6 +107,16 @@ function requestsCounterpartyLeaderSurface( return /(?:(?:кто|как\p{L}*|покаж\p{L}*|назов\p{L}*|раскро\p{L}*)[\s\S]{0,100}(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*|топ)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:главн\p{L}*|крупнейш\p{L}*|основн\p{L}*|ведущ\p{L}*)[\s\S]{0,80}(?:клиент|заказчик|поставщик|получател)|(?:топ[-\s]*(?:клиент|заказчик|поставщик|получател))|(?:клиент|поставщик)[\s\S]{0,80}(?:главн|крупнейш|основн|ведущ|топ))/iu.test(text); } +function requestsBusinessOverviewNextChecks( + turnMeaning: Record | null, + graph: Record | null +): boolean { + const text = normalizedTurnAndGraphText(turnMeaning, graph); + return /(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test( + text + ); +} + function requestsCompactCashflowAnswer( turnMeaning: Record | null, graph: Record | null @@ -527,6 +537,14 @@ function businessOverviewPeriodText(overview: Record): string { return period ? `за ${period}` : "за все доступное проверенное окно"; } +function businessOverviewPeriodPhrase(value: string | null | undefined): string { + const normalized = toNonEmptyString(value); + if (!normalized) { + return "за все доступное проверенное окно"; + } + return /^за\s+/iu.test(normalized) ? normalized : `за ${normalized}`; +} + function strongestIncomingYear(overview: Record): Record | null { const years = Array.isArray(overview.yearly_breakdown) ? overview.yearly_breakdown : []; const sorted = years @@ -1031,6 +1049,7 @@ function buildCompactBusinessOverviewReply( const compactCashflowRequested = directMoneyOnlyAnswer && requestsCompactCashflowAnswer(turnMeaning, graph); const cashflowPolarityRequested = compactCashflowRequested && requestsCashflowPolarityAnswer(turnMeaning, graph); const directAccountingProfitRequested = requestsDirectAccountingProfitAnswer(turnMeaning, graph); + const nextChecksRequested = requestsBusinessOverviewNextChecks(turnMeaning, graph); const rawMessage = toNonEmptyString(turnMeaning?.raw_message) ?? toNonEmptyString(turnMeaning?.effective_message); const earningsAmountRequested = /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0434\u043e\u0445\u043e\u0434\p{L}*)/iu.test( normalizeQuestionText(rawMessage ?? "") @@ -1268,10 +1287,13 @@ function buildCompactBusinessOverviewReply( ? `${vendorProcurementQuality.top_non_financial_supplier_share_pct}%` : null; const periodScope = toNonEmptyString(vendorProcurementQuality.period_scope) ?? period; - const totalText = totalOutgoing ? `; всего исходящих платежей в проверенном срезе ${totalOutgoing}` : ""; + const periodPhrase = businessOverviewPeriodPhrase(periodScope); + const totalText = totalOutgoing + ? `; всего исходящих платежей в проверенном срезе ${sentenceAmount(totalOutgoing)}` + : ""; if (status === "financial_institution_leads_outgoing_cash") { lines.push( - `Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.` + `Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} не подтверждает зависимость от обычного поставщика: крупнейший получатель исходящих денег ${topOutgoingName ?? "не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : ""}, но по названию это банк/финансовая организация${totalText}.` ); const financialHintText = financialFlowHintTextRuFromRecord(topOutgoingRecord); if (financialHintText) { @@ -1284,11 +1306,11 @@ function buildCompactBusinessOverviewReply( } } else if (status === "reviewed_procurement_concentration") { lines.push( - `Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей за ${periodScope} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.` + `Коротко: точный риск зависимости от одного поставщика не подтвержден полностью; проверка концентрации закупок/исходящих платежей ${periodPhrase} нашла крупнейшего получателя исходящего потока: ${topOutgoingName ?? nonFinancialName ?? "получатель не распознан"}${topOutgoingShare ? ` держит около ${topOutgoingShare}` : nonFinancialShare ? ` держит около ${nonFinancialShare}` : ""}${topOutgoingAmount ? ` (${topOutgoingAmount})` : nonFinancialAmount ? ` (${nonFinancialAmount})` : ""}${totalText}.` ); } else { lines.push( - `Коротко: проверка концентрации закупок/исходящих платежей за ${periodScope} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.` + `Коротко: проверка концентрации закупок/исходящих платежей ${periodPhrase} выполнена, но надежной небанковской концентрации поставщика по найденным исходящим платежам не хватает${totalText}.` ); } const contractText = @@ -1298,7 +1320,7 @@ function buildCompactBusinessOverviewReply( : ` Договорный профиль: используется ${vendorProcurementQuality.used_contracts} договоров.` : ""; lines.push( - `Что не доказано этим срезом: надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полная структура всех расходов.${contractText}` + `Это не аудит поставщиков: срез не доказывает надежность поставщика, качество поставок, договорные условия, назначение каждого платежа и полную структуру всех расходов.${contractText}` ); return joinBusinessReplyLines(lines); } @@ -1352,6 +1374,26 @@ function buildCompactBusinessOverviewReply( return joinBusinessReplyLines(lines); } + if ( + !separateSubject && + !crossScopeExecutiveSummary && + !counterpartyLeaderSurfaceRequested && + !rankingNeed && + nextChecksRequested + ) { + const subject = organizationScope ? `по компании ${organizationScope}` : "по компании"; + lines.push( + `В первую очередь ${subject} надо проверить чистую прибыль через закрытие 90/91/99, зависимость от топ-3 клиентов и поставщиков, долги/зависшие расчеты, складские остатки и НДС-позицию.` + ); + lines.push( + "Смысл порядка: сначала отделить бухгалтерский финрезультат от денежного нетто, затем проверить концентрацию потоков и только после этого углубляться в склад, долги и налоговую позицию." + ); + if (limitLine) { + lines.push(limitLine); + } + return joinBusinessReplyLines(lines); + } + if ( !separateSubject && !crossScopeExecutiveSummary && diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index ea7df24..88c600b 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -903,6 +903,22 @@ function hasDebtDueDateBoundaryFollowupSignal(text: string): boolean { return hasDueDateCue && hasFollowupShape; } +function hasSupplierDependencyFollowupSignal(text: string): boolean { + if (!text) { + return false; + } + const normalized = compactLower(text); + const hasSupplierOrOutgoingCue = + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0437\u0430\u043a\u0443\u043f|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436|\u0437\u0430\u043f\u043b\u0430\u0442|supplier|vendor|procurement|outgoing|payout)/iu.test( + normalized + ); + const hasDependencyCue = + /(?:\u0437\u0430\u0432\u0438\u0441\u0438\u043c|\u0437\u0430\u0432\u044f\u0437\u0430\u043d|\u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446|\u043a\u043b\u044e\u0447\u0435\u0432|\u043e\u0441\u043d\u043e\u0432\u043d|\u043a\u0440\u0438\u0442\u0438\u0447|dependency|concentration|key|critical)/iu.test( + normalized + ); + return hasSupplierOrOutgoingCue && hasDependencyCue; +} + function hasOrganizationLevelDebtDueDateOverviewSignal(text: string): boolean { if (!text) { return false; @@ -1082,7 +1098,7 @@ function hasBusinessOverviewContinuationSignal(text: string): boolean { normalized ); const hasAnalystContinuationCue = - /(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|налогов\p{L}*\s+вывод)/iu.test( + /(?:можно\s+ли|если\s+нет|proxy|прокси|аудит|оцен|что\s+думаешь|нормальн\p{L}*\s+прибыл|прибыл|марж|рентаб|ликвидн|просроч|качество\s+долг|риск|зависим|завязан|концентрац|налогов\p{L}*\s+вывод)/iu.test( normalized ); const hasTaxContinuationCue = @@ -1868,7 +1884,9 @@ export function buildAssistantMcpDiscoveryTurnInput( hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) || debtDueDateFollowupBusinessOverviewSignal; const supplierQualityBusinessOverviewSignal = - (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText); + ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + (hasOrganizationLevelSupplierQualityOverviewSignal(rawText) || hasSupplierDependencyFollowupSignal(rawText))) || + (businessOverviewContinuationSignal && hasSupplierDependencyFollowupSignal(rawText)); const profitMarginFollowupBusinessOverviewSignal = businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText); const profitMarginBusinessOverviewSignal = @@ -3086,6 +3104,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (debtDueDateFollowupBusinessOverviewSignal) { pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary"); } + if (supplierQualityBusinessOverviewSignal && hasSupplierDependencyFollowupSignal(rawText)) { + pushReason(reasonCodes, "mcp_discovery_business_overview_supplier_dependency_followup_boundary"); + } if (explicitVatSuppressesBusinessOverviewContinuation) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"); } diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index f55555c..903a367 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -426,6 +426,25 @@ function detectBroadBusinessEvaluation(text) { family: "broad_business_evaluation" }; } + if ( + /(?:что\s+(?:дальше\s+)?провер\w*|что\s+провер\w*\s+дальше|следующ\w*\s+шаг|в\s+первую\s+очередь\s+провер\w*|next\s+checks?|next\s+steps?)/iu.test( + normalized + ) + ) { + return { + family: "broad_business_evaluation" + }; + } + if ( + /(?:поставщик|закуп|исходящ|плат[её]ж|supplier|vendor|procurement|outgoing|payout)/iu.test(normalized) && + /(?:зависим|завязан|концентрац|ключев|основн|критич|dependency|concentration|key|critical)/iu.test( + normalized + ) + ) { + return { + family: "broad_business_evaluation" + }; + } if (hasOrganizationLevelEarningsOverviewSignal(normalized)) { return { family: "broad_business_evaluation" diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index cd19456..30973ab 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2949,6 +2949,12 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.reasons).toContain("unicode_business_overview_supplier_quality_deferred_to_discovery"); }); + it("defers supplier dependency follow-up wording to business overview discovery instead of bank ops by pseudo-counterparty", () => { + const result = resolveAddressIntent("а по поставщикам и исходящим платежам от кого зависимость?"); + expect(result.intent).toBe("unknown"); + expect(result.reasons).toContain("unicode_business_overview_supplier_quality_deferred_to_discovery"); + }); + it("routes reconciliation mismatch wording into open contracts intent", () => { const result = resolveAddressIntent( "Покажи контрагентов, по которым сальдо скорее всего не совпадет с их актом сверки. Может, стоит поторопиться и запросить сверку?" diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 0ca1fd0..e09e1f8 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -465,6 +465,64 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("business_overview_route_template_v1"); }); + it("formats reviewed supplier dependency answers without duplicated period or punctuation", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "vendor_risk_procurement_boundary", + unsupported_but_understood_family: "vendor_risk_procurement_boundary" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + vendor_procurement_quality: { + period_scope: "за все доступное проверенное окно", + total_outgoing_amount_human_ru: "137 963 720,37 руб.", + top_outgoing_counterparty: { + axis_value: "ГТК-Интер, ООО", + total_amount_human_ru: "44 943 460 руб." + }, + top_outgoing_share_pct: 32.58, + used_contracts: 291, + total_contracts: 394, + used_contract_share_pct: 73.86, + evidence_status: "reviewed_procurement_concentration" + } + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Vendor route.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("ГТК-Интер, ООО держит около 32.58% (44 943 460 руб.)"); + expect(candidate.reply_text).toContain("Это не аудит поставщиков"); + expect(candidate.reply_text).not.toContain("за за"); + expect(candidate.reply_text).not.toContain("руб.."); + }); + it("uses a compact direct answer for business-overview top year questions", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ @@ -1013,6 +1071,67 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("wide overview"); }); + it("answers business overview next-check follow-ups with a concrete analyst plan first", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + raw_message: "что дальше проверять в первую очередь?", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + incoming_customer_revenue: { + total_amount_human_ru: "285 819 547,57 руб.", + coverage_limited_by_probe_limit: true + }, + outgoing_supplier_payout: { + total_amount_human_ru: "137 963 720,37 руб.", + coverage_limited_by_probe_limit: true + }, + net_amount_human_ru: "147 855 827,20 руб.", + net_direction: "net_incoming", + top_customers: [{ axis_value: "Комитет государственных услуг г. Москвы", total_amount_human_ru: "133 839 880,82 руб." }], + top_suppliers: [{ axis_value: "ГТК-Интер, ООО", total_amount_human_ru: "44 943 460 руб." }] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "generic advice should not leak", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + const firstLine = candidate.reply_text?.split("\n")[0] ?? ""; + expect(firstLine).toContain("чистую прибыль через закрытие 90/91/99"); + expect(firstLine).toContain("топ-3"); + expect(firstLine).toContain("долги"); + expect(firstLine).toContain("складские остатки"); + expect(candidate.reply_text).toContain("не считаю полным бухгалтерским оборотом"); + expect(candidate.reply_text).not.toContain("generic advice"); + }); + it("keeps counterparty leaders for money followups instead of compact cashflow", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 6549469..77fbcdf 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -2637,6 +2637,70 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.clarification_gaps).toEqual([]); }); + it("keeps supplier dependency follow-ups in business overview instead of pseudo-counterparty bank operations", () => { + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а по поставщикам и исходящим платежам от кого зависимость?", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "list_movements", + explicit_intent_candidate: "bank_operations_by_counterparty", + explicit_entity_candidates: ["поставщикам"] + }, + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_discovery_loop_selected_chain_id: "business_overview", + previous_filters: { + organization: orgName + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "vendor_risk_procurement_boundary", + explicit_organization_scope: orgName, + unsupported_but_understood_family: "vendor_risk_procurement_boundary", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).toContain("mcp_discovery_business_overview_supplier_dependency_followup_boundary"); + }); + + it("keeps supplier dependency follow-ups in the procurement boundary after broad-business meaning normalization", () => { + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а по поставщикам и исходящим платежам от кого зависимость?", + assistantTurnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: orgName, + unsupported_but_understood_family: "broad_business_evaluation" + }, + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { + organization: orgName + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "vendor_risk_procurement_boundary", + explicit_organization_scope: orgName, + unsupported_but_understood_family: "vendor_risk_procurement_boundary", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_business_overview_supplier_dependency_followup_boundary"); + }); + it("resumes an open-scope total clarification loop from saved state when the user resolves the pending period with all-time wording", () => { const orgName = "ООО Альтернатива Плюс"; const result = buildAssistantMcpDiscoveryTurnInput({ diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index 2ea01b6..868a47f 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -303,6 +303,22 @@ describe("assistantTurnMeaningPolicy", () => { expect(supplierQuality.asked_action_family).toBe("broad_evaluation"); expect(supplierQuality.unsupported_but_understood_family).toBe("broad_business_evaluation"); expect(supplierQuality.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); + + const supplierDependencyFollowup = policy.resolveAssistantTurnMeaning({ + rawUserMessage: "а по поставщикам и исходящим платежам от кого зависимость?" + }); + expect(supplierDependencyFollowup.explicit_intent_candidate).toBeNull(); + expect(supplierDependencyFollowup.asked_domain_family).toBe("business_summary"); + expect(supplierDependencyFollowup.asked_action_family).toBe("broad_evaluation"); + expect(supplierDependencyFollowup.unsupported_but_understood_family).toBe("broad_business_evaluation"); + + const nextChecks = policy.resolveAssistantTurnMeaning({ + rawUserMessage: "что дальше проверять в первую очередь?" + }); + expect(nextChecks.explicit_intent_candidate).toBeNull(); + expect(nextChecks.asked_domain_family).toBe("business_summary"); + expect(nextChecks.asked_action_family).toBe("broad_evaluation"); + expect(nextChecks.unsupported_but_understood_family).toBe("broad_business_evaluation"); }); it("keeps selected-object profitability as an inventory exact intent instead of company overview", () => { diff --git a/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts index 2ef757b..8068958 100644 --- a/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts +++ b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts @@ -149,6 +149,40 @@ describe("counterparty analytics reply builders", () => { expect(reply.text).not.toContain("1. 2020 |"); }); + it("preserves raw dependency wording when the address lane receives a canonicalized inflow ranking", () => { + const reply = composeFactualReply( + "customer_revenue_and_payments", + [ + { + period: "2020-03-01T00:00:00Z", + registrator: "Поступление 1", + account_dt: "", + account_kt: "", + amount: 500, + analytics: ["Малый клиент", "Договор 1"] + }, + { + period: "2020-03-02T00:00:00Z", + registrator: "Поступление 2", + account_dt: "", + account_kt: "", + amount: 1200, + analytics: ["Главный клиент", "Договор 2"] + } + ], + { + rawUserMessage: "от кого мы больше всего зависим по входящим деньгам?", + userMessage: "определить контрагентов с наибольшим объемом входящих денежных потоков" + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text.split("\n")[0]).toContain("Главный сигнал зависимости по входящим деньгам"); + expect(reply.text.split("\n")[0]).toContain("Главный клиент"); + expect(reply.text).toContain("сигнал концентрации"); + expect(reply.text).toContain("не чистая прибыль"); + }); + it("explains organization activity age as 1C activity rather than legal age", () => { const reply = composeFactualReply( "counterparty_activity_lifecycle",