diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index e4df264..c6944ed 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -127,6 +127,48 @@ function readNavigationDiscoveryCounterparty(debug) { } return null; } +function readDiscoveryPilot(debug) { + const entry = toObject(debug.assistant_mcp_discovery_entry_point_v1); + const bridge = toObject(entry?.bridge); + return toObject(bridge?.pilot); +} +function readCurrentDiscoveryCounterparty(debug) { + const pilot = readDiscoveryPilot(debug); + const entityResolution = toObject(pilot?.derived_entity_resolution); + const bidirectionalValueFlow = toObject(pilot?.derived_bidirectional_value_flow); + const valueFlow = toObject(pilot?.derived_value_flow); + return (toNonEmptyString(entityResolution?.resolved_entity) ?? + toNonEmptyString(bidirectionalValueFlow?.counterparty) ?? + toNonEmptyString(valueFlow?.counterparty) ?? + (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? + readNavigationDiscoveryCounterparty(debug)); +} +function isCounterpartyDiscoveryChain(value) { + return (value === "entity_resolution" || + value === "value_flow" || + value === "value_flow_comparison" || + value === "document_evidence" || + value === "movement_evidence" || + value === "lifecycle"); +} +function intentFromDiscoveryChain(value) { + if (value === "business_overview") { + return "business_overview"; + } + if (value === "value_flow" || value === "value_flow_comparison") { + return "customer_revenue_and_payments"; + } + if (value === "document_evidence") { + return "list_documents_by_counterparty"; + } + if (value === "movement_evidence") { + return "bank_operations_by_counterparty"; + } + if (value === "lifecycle") { + return "counterparty_activity_lifecycle"; + } + return "unknown"; +} function toAddressFocusObjectType(value) { const normalized = toNonEmptyString(value); if (!normalized) { @@ -364,8 +406,7 @@ function readBusinessOverviewComparisonProofBundles(debug) { const entryPoint = toObject(debug.assistant_mcp_discovery_entry_point_v1); const turnInput = toObject(entryPoint?.turn_input); const turnMeaning = toObject(turnInput?.turn_meaning_ref); - const bridge = toObject(entryPoint?.bridge); - const pilot = toObject(bridge?.pilot); + const pilot = readDiscoveryPilot(debug); const counterpartyValueFlowBundle = cloneRecord(turnMeaning?.previous_counterparty_value_flow_bundle) ?? cloneRecord(pilot?.derived_bidirectional_value_flow); const counterpartyDocumentBundle = cloneRecord(turnMeaning?.previous_counterparty_document_bundle); @@ -383,6 +424,18 @@ function readBusinessOverviewComparisonProofBundles(debug) { counterparty_document_bundle: counterpartyDocumentBundle }; } +function readCounterpartyValueFlowProofBundles(debug) { + const pilot = readDiscoveryPilot(debug); + const counterpartyValueFlowBundle = cloneRecord(pilot?.derived_bidirectional_value_flow); + const counterparty = toNonEmptyString(counterpartyValueFlowBundle?.counterparty); + if (!counterparty || !counterpartyValueFlowBundle) { + return null; + } + return { + counterparty_value_flow_bundle: counterpartyValueFlowBundle, + counterparty_document_bundle: null + }; +} function buildFocusObjectFromDebug(debug, resultSetId, createdAt) { const extractedFilters = toObject(debug.extracted_filters) ?? {}; const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); @@ -396,8 +449,8 @@ function buildFocusObjectFromDebug(debug, resultSetId, createdAt) { return buildFocusObject("organization", organization, resultSetId, createdAt); } } - if (selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true) { - const counterparty = (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug); + if (isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true) { + const counterparty = readCurrentDiscoveryCounterparty(debug); if (counterparty) { return buildFocusObject("counterparty", counterparty, resultSetId, createdAt); } @@ -587,14 +640,13 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { } const debug = item.debug; const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); - const discoveryIntent = selectedDiscoveryChain === "business_overview" - ? "business_overview" - : selectedDiscoveryChain === "value_flow_comparison" - ? "customer_revenue_and_payments" - : "unknown"; const detectedIntent = toNonEmptyString(debug.detected_intent); - const intent = toAddressIntent(detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent); + const discoveryIntent = intentFromDiscoveryChain(selectedDiscoveryChain); const trackableDiscoveryTurn = debug.mcp_discovery_response_applied === true && Boolean(selectedDiscoveryChain); + const shouldPreferDiscoveryIntent = trackableDiscoveryTurn && + discoveryIntent !== "unknown" && + selectedDiscoveryChain !== "business_overview"; + const intent = toAddressIntent(shouldPreferDiscoveryIntent ? discoveryIntent : detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent); if (intent === "unknown" && !trackableDiscoveryTurn) { return state; } @@ -603,8 +655,8 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { const routeId = toNonEmptyString(debug.selected_recipe) ?? selectedDiscoveryChain; const filters = sanitizeBusinessOverviewNavigationFilters(normalizeFilters(debug.extracted_filters), debug); const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text) ?? (0, assistantContinuityPolicy_1.readAddressDebugOrganization)(debug, toNonEmptyString); - const derivedCounterpartyScope = selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true - ? (0, assistantContinuityPolicy_1.readAddressDebugCounterparty)(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug) + const derivedCounterpartyScope = isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true + ? readCurrentDiscoveryCounterparty(debug) : null; const filtersWithDerivedScope = derivedOrganizationScope && !toNonEmptyString(filters.organization) ? { @@ -637,8 +689,12 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { const debugFocusObject = buildFocusObjectFromDebug(debug, resultSetId, createdAt); const primaryEntityFocusObject = buildFocusObjectFromPrimaryEntityRef(resultSet, createdAt); const focusObject = resolveFocusObjectForNavigation(state, intent, resultSet, debugFocusObject, primaryEntityFocusObject); - const rawComparisonCounterparty = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true - ? readNavigationDiscoveryCounterparty(debug) + const rawComparisonCounterparty = debug.mcp_discovery_response_applied === true + ? selectedDiscoveryChain === "business_overview" + ? readNavigationDiscoveryCounterparty(debug) + : isCounterpartyDiscoveryChain(selectedDiscoveryChain) + ? readCurrentDiscoveryCounterparty(debug) + : null : null; const comparisonCounterparty = rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty) ? rawComparisonCounterparty @@ -648,7 +704,9 @@ function evolveAddressNavigationStateWithAssistantItem(state, item, turnIndex) { : null; const currentComparisonProofBundles = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? readBusinessOverviewComparisonProofBundles(debug) - : null; + : selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true + ? readCounterpartyValueFlowProofBundles(debug) + : null; const inheritedComparisonScope = state.session_context.comparison_scope; const inheritedComparisonProofBundles = comparisonCounterparty && sameBusinessLabel(inheritedComparisonScope?.counterparty?.label, comparisonCounterparty) diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index ec7e785..45cc6b3 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -1313,6 +1313,15 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { intent === "vat_payable_confirmed_as_of_date"; const currentHasPeriod = hasExplicitPeriodWindow(merged); const previousHasPeriod = hasExplicitPeriodWindow(previous); + const currentPeriodFromForCarryover = toNonEmptyString(merged.period_from); + const currentPeriodToForCarryover = toNonEmptyString(merged.period_to); + const todayIsoForPeriodCarryover = new Date().toISOString().slice(0, 10); + const currentValueFlowPeriodDefaultsToToday = isValueCounterpartyIntent(intent) && + hasFollowupSignal && + !hasExplicitPeriodInMessage && + !hasExplicitCurrentDateInMessage && + !currentPeriodFromForCarryover && + currentPeriodToForCarryover === todayIsoForPeriodCarryover; const currentCounterpartyExplicit = toNonEmptyString(merged.counterparty); const currentContractExplicit = toNonEmptyString(merged.contract); const currentItemExplicit = toNonEmptyString(merged.item); @@ -1387,7 +1396,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } reasons.push("period_from_followup_context"); } - if (!currentHasPeriod && + if ((!currentHasPeriod || currentValueFlowPeriodDefaultsToToday) && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage && diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 2558c1b..fb74dc3 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -778,7 +778,11 @@ function resolveFollowupTargetIntent(inventoryPurchaseDateVatBridge, selectedObj } function shouldUseNavigationTemporalCarryover(sourceIntentHint) { const normalizedIntent = fallbackToNonEmptyString(sourceIntentHint); - return (normalizedIntent === "inventory_on_hand_as_of_date" || + return (normalizedIntent === "customer_revenue_and_payments" || + normalizedIntent === "supplier_payouts_profile" || + normalizedIntent === "contract_usage_and_value" || + normalizedIntent === "counterparty_activity_lifecycle" || + normalizedIntent === "inventory_on_hand_as_of_date" || normalizedIntent === "inventory_supplier_stock_overlap_as_of_date" || normalizedIntent === "inventory_purchase_provenance_for_item" || normalizedIntent === "inventory_purchase_documents_for_item" || diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index dcb968d..9d9c840 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -212,7 +212,7 @@ function valueFlowDirectionLabelRu(pilot) { ? "исходящих платежей/списаний" : "входящих денежных поступлений"; } -function valueFlowZeroResultConfirmedLine(pilot) { +function valueFlowZeroResultInsufficiencyLine(pilot) { if (!hasExecutedZeroValueFlowRows(pilot)) { return null; } @@ -224,7 +224,22 @@ function valueFlowZeroResultConfirmedLine(pilot) { const period = explicitDateScope(pilot); const organizationPart = organization ? ` по организации ${organization}` : ""; const periodPart = period ? ` за период ${period}` : " в проверенном окне"; - return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart}: 0 руб.; ${valueFlowDirectionLabelRu(pilot)} не найдено.`; + const direction = valueFlowDirectionLabelRu(pilot); + return `Точную сумму ${direction} по контрагенту ${counterparty}${organizationPart}${periodPart} не подтверждаю: в выполненной проверке 1С строки ${direction} не найдены; это не доказывает отсутствие операций вне доступного банковского контура.`; +} +function valueFlowZeroResultCheckedLine(pilot) { + if (!hasExecutedZeroValueFlowRows(pilot)) { + return null; + } + const counterparty = firstEntityCandidate(pilot); + if (!counterparty) { + return null; + } + const organization = explicitOrganizationScope(pilot); + const period = explicitDateScope(pilot); + const organizationPart = organization ? ` по организации ${organization}` : ""; + const periodPart = period ? ` за период ${period}` : " в проверенном окне"; + return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart} строки ${valueFlowDirectionLabelRu(pilot)} не найдено.`; } function valueFlowZeroResultUnknownLine(pilot) { if (!hasExecutedZeroValueFlowRows(pilot)) { @@ -239,11 +254,11 @@ function valueFlowZeroResultUnknownLine(pilot) { return `Это не доказывает отсутствие операций с контрагентом ${counterparty}${periodPart} или вне доступного банковского контура.`; } function valueFlowZeroResultHeadline(pilot) { - const confirmedLine = valueFlowZeroResultConfirmedLine(pilot); - if (!confirmedLine) { + const insufficiencyLine = valueFlowZeroResultInsufficiencyLine(pilot); + if (!insufficiencyLine) { return null; } - return confirmedLine; + return insufficiencyLine; } function hasAllTimeScope(pilot) { return (dryRunHasAxis(pilot, "all_time_scope") || @@ -1846,20 +1861,20 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { ? [derivedValueLine] : derivedValueLine ? [derivedValueLine, ...monthlyConfirmedLines] - : valueFlowZeroResultConfirmedLine(pilot) - ? [valueFlowZeroResultConfirmedLine(pilot)] - : derivedEntityResolutionLine - ? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine] - : derivedMetadataLine - ? [derivedMetadataLine] - : pilot.evidence.confirmed_facts; + : derivedEntityResolutionLine + ? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine] + : derivedMetadataLine + ? [derivedMetadataLine] + : pilot.evidence.confirmed_facts; const unknownLines = pilot.derived_business_overview ? businessOverviewUnknownLines(pilot) : pilot.derived_metadata_surface ? pilot.derived_metadata_surface.available_fields.length > 0 ? userFacingUnknowns(pilot.evidence.unknown_facts) : ["Детальный список полей этих объектов этим шагом не получен."] - : appendValueFlowZeroResultUnknown(rankedValueFlowUnknownLines(pilot), pilot); + : appendValueFlowZeroResultUnknown(valueFlowZeroResultCheckedLine(pilot) + ? [valueFlowZeroResultCheckedLine(pilot), ...rankedValueFlowUnknownLines(pilot)] + : rankedValueFlowUnknownLines(pilot), pilot); return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryAnswerAdapter", diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 5683448..e847178 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -2209,9 +2209,28 @@ function deriveBusinessOverviewContractUsageProfile(result, periodScope) { }; } function deriveValueFlow(result, counterparty, periodScope, direction, aggregationAxis) { - if (!result || result.error || result.matched_rows <= 0) { + if (!result || result.error) { return null; } + if (result.matched_rows <= 0) { + return { + value_flow_direction: direction, + counterparty, + period_scope: periodScope, + aggregation_axis: aggregationAxis, + rows_matched: 0, + rows_with_amount: 0, + total_amount: 0, + total_amount_human_ru: formatAmountHumanRu(0), + first_movement_date: null, + latest_movement_date: null, + coverage_limited_by_probe_limit: result.coverage_limited_by_probe_limit, + coverage_recovered_by_period_chunking: result.coverage_recovered_by_period_chunking, + period_chunking_granularity: result.period_chunking_granularity, + monthly_breakdown: [], + inference_basis: "sum_of_confirmed_1c_value_flow_rows" + }; + } let totalAmount = 0; let rowsWithAmount = 0; for (const row of result.rows) { @@ -3926,16 +3945,30 @@ function buildMovementConfirmedFacts(result, counterparty, periodScope) { return [`В 1С найдены строки движений${checkedCounterpartySuffixRu(counterparty)}${checkedPeriodSuffixRu(periodScope)}.`]; } function buildValueFlowConfirmedFacts(result, counterparty, direction) { - if (result.error || result.matched_rows <= 0) { + if (result.error) { return []; } if (direction === "outgoing_supplier_payout") { + if (result.matched_rows <= 0) { + return [ + counterparty + ? `1C supplier-payout rows were checked for counterparty ${counterparty}: not_found` + : "1C supplier-payout rows were checked for the requested counterparty scope: not_found" + ]; + } return [ counterparty ? `1C supplier-payout rows were found for counterparty ${counterparty}` : "1C supplier-payout rows were found for the requested counterparty scope" ]; } + if (result.matched_rows <= 0) { + return [ + counterparty + ? `1C value-flow rows were checked for counterparty ${counterparty}: not_found` + : "1C value-flow rows were checked for the requested counterparty scope: not_found" + ]; + } return [ counterparty ? `1C value-flow rows were found for counterparty ${counterparty}` @@ -4115,6 +4148,12 @@ function buildValueFlowInferredFacts(derived) { return []; } const facts = []; + if (derived.rows_matched <= 0) { + facts.push(derived.value_flow_direction === "outgoing_supplier_payout" + ? "Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set" + : "Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set"); + return facts; + } if (derived.value_flow_direction === "outgoing_supplier_payout") { facts.push("Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js index 9f8a923..8172c61 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -261,13 +261,14 @@ function resolveAssistantMcpDiscoveryEvidence(input) { const rowsMatched = probeRowsMatched(probeResults); const rowsReceived = probeRowsReceived(probeResults); const bypassDetected = hasProbeBypass(input.plan, probeResults); + const confirmedNoMatchFact = confirmedFacts.some((fact) => /\bnot_found\b/iu.test(String(fact ?? ""))); if (bypassDetected) { pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan"); } if (input.plan.plan_status !== "allowed") { pushReason(reasonCodes, "plan_not_allowed_by_runtime"); } - if (confirmedFacts.length > 0 && rowsMatched <= 0) { + if (confirmedFacts.length > 0 && rowsMatched <= 0 && !confirmedNoMatchFact) { pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows"); } if (!sourceRowsSummary && rowsReceived > 0) { @@ -287,6 +288,12 @@ function resolveAssistantMcpDiscoveryEvidence(input) { answerPermission = "confirmed_answer"; pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence"); } + else if (confirmedFacts.length > 0 && confirmedNoMatchFact && rowsMatched <= 0 && sourceRowsSummary) { + evidenceStatus = "confirmed"; + coverageStatus = "full"; + answerPermission = "confirmed_answer"; + pushReason(reasonCodes, "confirmed_no_match_fact_with_allowed_mcp_evidence"); + } else if (inferredFacts.length > 0 && rowsReceived > 0) { evidenceStatus = "inferred_only"; coverageStatus = "partial"; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index b68c85f..5f541b5 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -211,9 +211,15 @@ function localizeLine(value) { if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки входящих денежных поступлений в запрошенном срезе."; } + if (/^1C value-flow rows were checked for the requested counterparty scope: not_found$/i.test(value)) { + return "В 1С проверены входящие денежные строки в запрошенном срезе: строки не найдены."; + } if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки исходящих платежей и списаний в запрошенном срезе."; } + if (/^1C supplier-payout rows were checked for the requested counterparty scope: not_found$/i.test(value)) { + return "В 1С проверены исходящие платежи/списания в запрошенном срезе: строки не найдены."; + } const openScopeBidirectionalMatch = value.match(/^1C bidirectional value-flow rows were checked for the requested counterparty scope: incoming=(found|not_found), outgoing=(found|not_found)$/i); if (openScopeBidirectionalMatch) { const incoming = openScopeBidirectionalMatch[1] === "found" @@ -238,6 +244,10 @@ function localizeLine(value) { if (valueFlowMatch) { return `В 1С найдены строки входящих денежных поступлений по контрагенту ${valueFlowMatch[1]}.`; } + const valueFlowNotFoundMatch = value.match(/^1C value-flow rows were checked for counterparty\s+(.+): not_found$/i); + if (valueFlowNotFoundMatch) { + return `В 1С проверены входящие денежные строки по контрагенту ${valueFlowNotFoundMatch[1]}: строки не найдены.`; + } if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки входящих денежных поступлений по запрошенному контрагентскому контуру."; } @@ -259,6 +269,10 @@ function localizeLine(value) { if (supplierPayoutMatch) { return `В 1С найдены строки исходящих платежей/списаний по контрагенту ${supplierPayoutMatch[1]}.`; } + const supplierPayoutNotFoundMatch = value.match(/^1C supplier-payout rows were checked for counterparty\s+(.+): not_found$/i); + if (supplierPayoutNotFoundMatch) { + return `В 1С проверены исходящие платежи/списания по контрагенту ${supplierPayoutNotFoundMatch[1]}: строки не найдены.`; + } if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки исходящих платежей/списаний по запрошенному контрагентскому контуру."; } @@ -292,6 +306,12 @@ function localizeLine(value) { if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) { return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С."; } + if (/^Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) { + return "Нулевую сумму исходящих платежей можно трактовать только внутри этого проверенного среза 1С, где строки списаний не найдены."; + } + if (/^Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) { + return "Нулевую сумму входящих поступлений можно трактовать только внутри этого проверенного среза 1С, где строки поступлений не найдены."; + } if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) { return "Нетто денежного потока рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи в 1С."; } @@ -1159,7 +1179,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { outgoingAmount ? `исходящие ${outgoingAmount}` : null, netAmount ? `${netDirection} ${sentenceAmount(netAmount) ?? netAmount}` : null ].filter((value) => Boolean(value)); - lines.push(`По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, не аудиторское заключение и не подтвержденная чистая прибыль.`); + lines.push(`По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, а не финансовый аудит и не подтверждение чистой прибыли.`); lines.push("Интерпретация: по этому срезу видны крупные контрактные денежные потоки и заметная зависимость от нескольких крупных контрагентов, а не равномерный поток мелких продаж."); lines.push("Что видно в ограниченном денежном срезе:"); if (incomingAmount) { @@ -1194,7 +1214,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { lines.push(`- ${localizeLine(debtLine)}`); } if (!limitLine) { - lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль."); + lines.push("Ограничение: это оценка по денежным потокам и найденным срезам 1С, а не финансовый аудит и не подтверждение чистой прибыли."); } const missingOverviewFamilies = []; if (!taxLine) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 66dc047..392a3ef 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -615,6 +615,53 @@ function hasExactBankOperationsAddressReply(input, entryPoint) { routeMode === "exact" || hasFullConfirmedTruth(input)); } +function extractExplicitDateScopeYear(value) { + const source = toNonEmptyString(value); + if (!source) { + return null; + } + const match = source.match(/\b((?:19|20)\d{2})\b/u); + return match ? match[1] : null; +} +function extractDocumentReplyDateYears(value) { + const source = String(value ?? ""); + if (!source) { + return []; + } + const years = new Set(); + for (const match of source.matchAll(/\b((?:19|20)\d{2})-\d{2}-\d{2}(?:T|\b)/gu)) { + years.add(match[1]); + } + for (const match of source.matchAll(/\b\d{1,2}\.\d{1,2}\.((?:19|20)\d{2})\b/gu)) { + years.add(match[1]); + } + return [...years]; +} +function hasDocumentListTemporalConflictWithDiscovery(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const isDocumentIntent = detectedIntent === "list_documents_by_counterparty" || detectedIntent === "list_documents_by_contract"; + const isDocumentRecipe = selectedRecipe === "address_documents_by_counterparty_v1" || + selectedRecipe === "address_documents_by_contract_v1"; + if (!isDocumentIntent || !isDocumentRecipe) { + return false; + } + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const expectedYear = extractExplicitDateScopeYear(turnMeaning?.explicit_date_scope); + if (!expectedYear) { + return false; + } + const filters = toRecordObject(input.addressRuntimeMeta?.extracted_filters); + const periodFrom = toNonEmptyString(filters?.period_from); + const periodTo = toNonEmptyString(filters?.period_to); + const hasMatchingPeriod = Boolean(periodFrom?.startsWith(`${expectedYear}-`)) && Boolean(periodTo?.startsWith(`${expectedYear}-`)); + const replyDateYears = extractDocumentReplyDateYears(input.currentReply); + const hasOutOfScopeReplyDates = replyDateYears.some((year) => year !== expectedYear); + return hasOutOfScopeReplyDates || (!hasMatchingPeriod && replyDateYears.length > 0); +} function hasExactDocumentListAddressReply(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; @@ -968,7 +1015,8 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const staleMetadataDiscoveryFallbackAgainstExactAddressReply = hasStaleMetadataDiscoveryFallbackAgainstExactAddressReply(input, entryPoint); const exactValueFlowReplyForBusinessOverviewDirectMoneyNeed = hasExactValueFlowReplyForBusinessOverviewDirectMoneyNeed(input, entryPoint); const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint); - const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint); + const documentListTemporalConflictWithDiscovery = hasDocumentListTemporalConflictWithDiscovery(input, entryPoint); + const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint) && !documentListTemporalConflictWithDiscovery; const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint); const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); @@ -987,10 +1035,12 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !groundedMovementCandidatePriority && !businessOverviewBoundaryCandidatePriority && !groundedValueFlowRankingCandidatePriority; - const alignedFactualAddressReplyProtectsCurrent = alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; - const matchedFactualAddressContinuationTargetProtectsCurrent = matchedFactualAddressContinuationTarget && !groundedValueFlowRankingCandidatePriority; - const fullConfirmedFactualAddressReplyProtectsCurrent = fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; - const exactMatchedFactualAddressReplyProtectsCurrent = exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + const alignedFactualAddressReplyProtectsCurrent = alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery; + const matchedFactualAddressContinuationTargetProtectsCurrent = matchedFactualAddressContinuationTarget && + !groundedValueFlowRankingCandidatePriority && + !documentListTemporalConflictWithDiscovery; + const fullConfirmedFactualAddressReplyProtectsCurrent = fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery; + const exactMatchedFactualAddressReplyProtectsCurrent = exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery; const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && !valueFlowActionConflictWithDiscoveryTurnMeaning; @@ -1024,6 +1074,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (evidenceLaneConflictWithDiscoveryTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override"); } + if (documentListTemporalConflictWithDiscovery) { + pushReason(reasonCodes, "mcp_discovery_response_policy_document_list_temporal_conflict_allows_candidate_override"); + } if (currentClarificationProtectsCurrentReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_current_clarification_required_reply"); } diff --git a/llm_normalizer/backend/src/services/addressNavigationState.ts b/llm_normalizer/backend/src/services/addressNavigationState.ts index 42444df..66d3388 100644 --- a/llm_normalizer/backend/src/services/addressNavigationState.ts +++ b/llm_normalizer/backend/src/services/addressNavigationState.ts @@ -151,6 +151,56 @@ function readNavigationDiscoveryCounterparty(debug: Record): st return null; } +function readDiscoveryPilot(debug: Record): Record | null { + const entry = toObject(debug.assistant_mcp_discovery_entry_point_v1); + const bridge = toObject(entry?.bridge); + return toObject(bridge?.pilot); +} + +function readCurrentDiscoveryCounterparty(debug: Record): string | null { + const pilot = readDiscoveryPilot(debug); + const entityResolution = toObject(pilot?.derived_entity_resolution); + const bidirectionalValueFlow = toObject(pilot?.derived_bidirectional_value_flow); + const valueFlow = toObject(pilot?.derived_value_flow); + return ( + toNonEmptyString(entityResolution?.resolved_entity) ?? + toNonEmptyString(bidirectionalValueFlow?.counterparty) ?? + toNonEmptyString(valueFlow?.counterparty) ?? + readAddressDebugCounterparty(debug, toNonEmptyString) ?? + readNavigationDiscoveryCounterparty(debug) + ); +} + +function isCounterpartyDiscoveryChain(value: string | null): boolean { + return ( + value === "entity_resolution" || + value === "value_flow" || + value === "value_flow_comparison" || + value === "document_evidence" || + value === "movement_evidence" || + value === "lifecycle" + ); +} + +function intentFromDiscoveryChain(value: string | null): AddressIntent { + if (value === "business_overview") { + return "business_overview"; + } + if (value === "value_flow" || value === "value_flow_comparison") { + return "customer_revenue_and_payments"; + } + if (value === "document_evidence") { + return "list_documents_by_counterparty"; + } + if (value === "movement_evidence") { + return "bank_operations_by_counterparty"; + } + if (value === "lifecycle") { + return "counterparty_activity_lifecycle"; + } + return "unknown"; +} + function toAddressFocusObjectType(value: unknown): AddressFocusObjectType { const normalized = toNonEmptyString(value); if (!normalized) { @@ -437,8 +487,7 @@ function readBusinessOverviewComparisonProofBundles(debug: Record): AddressComparisonProofBundles | null { + const pilot = readDiscoveryPilot(debug); + const counterpartyValueFlowBundle = cloneRecord(pilot?.derived_bidirectional_value_flow); + const counterparty = toNonEmptyString(counterpartyValueFlowBundle?.counterparty); + if (!counterparty || !counterpartyValueFlowBundle) { + return null; + } + return { + counterparty_value_flow_bundle: counterpartyValueFlowBundle, + counterparty_document_bundle: null + }; +} + function buildFocusObjectFromDebug(debug: Record, resultSetId: string, createdAt: string): AddressFocusObject | null { const extractedFilters = toObject(debug.extracted_filters) ?? {}; const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); @@ -472,9 +534,8 @@ function buildFocusObjectFromDebug(debug: Record, resultSetId: return buildFocusObject("organization", organization, resultSetId, createdAt); } } - if (selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true) { - const counterparty = - readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug); + if (isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true) { + const counterparty = readCurrentDiscoveryCounterparty(debug); if (counterparty) { return buildFocusObject("counterparty", counterparty, resultSetId, createdAt); } @@ -697,15 +758,16 @@ export function evolveAddressNavigationStateWithAssistantItem( } const debug = item.debug as unknown as Record; const selectedDiscoveryChain = toNonEmptyString(debug.mcp_discovery_selected_chain_id); - const discoveryIntent = - selectedDiscoveryChain === "business_overview" - ? "business_overview" - : selectedDiscoveryChain === "value_flow_comparison" - ? "customer_revenue_and_payments" - : "unknown"; const detectedIntent = toNonEmptyString(debug.detected_intent); - const intent = toAddressIntent(detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent); + const discoveryIntent = intentFromDiscoveryChain(selectedDiscoveryChain); const trackableDiscoveryTurn = debug.mcp_discovery_response_applied === true && Boolean(selectedDiscoveryChain); + const shouldPreferDiscoveryIntent = + trackableDiscoveryTurn && + discoveryIntent !== "unknown" && + selectedDiscoveryChain !== "business_overview"; + const intent = toAddressIntent( + shouldPreferDiscoveryIntent ? discoveryIntent : detectedIntent && detectedIntent !== "unknown" ? detectedIntent : discoveryIntent + ); if (intent === "unknown" && !trackableDiscoveryTurn) { return state; } @@ -716,8 +778,8 @@ export function evolveAddressNavigationStateWithAssistantItem( const derivedOrganizationScope = resolveDerivedOrganizationScope(debug, filters, item.text) ?? readAddressDebugOrganization(debug, toNonEmptyString); const derivedCounterpartyScope = - selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true - ? readAddressDebugCounterparty(debug, toNonEmptyString) ?? readNavigationDiscoveryCounterparty(debug) + isCounterpartyDiscoveryChain(selectedDiscoveryChain) && debug.mcp_discovery_response_applied === true + ? readCurrentDiscoveryCounterparty(debug) : null; const filtersWithDerivedScope = derivedOrganizationScope && !toNonEmptyString(filters.organization) @@ -758,8 +820,12 @@ export function evolveAddressNavigationStateWithAssistantItem( primaryEntityFocusObject ); const rawComparisonCounterparty = - selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true - ? readNavigationDiscoveryCounterparty(debug) + debug.mcp_discovery_response_applied === true + ? selectedDiscoveryChain === "business_overview" + ? readNavigationDiscoveryCounterparty(debug) + : isCounterpartyDiscoveryChain(selectedDiscoveryChain) + ? readCurrentDiscoveryCounterparty(debug) + : null : null; const comparisonCounterparty = rawComparisonCounterparty && !shouldSuppressBusinessOverviewCounterpartyFocus(debug, rawComparisonCounterparty) @@ -772,6 +838,8 @@ export function evolveAddressNavigationStateWithAssistantItem( const currentComparisonProofBundles = selectedDiscoveryChain === "business_overview" && debug.mcp_discovery_response_applied === true ? readBusinessOverviewComparisonProofBundles(debug) + : selectedDiscoveryChain === "value_flow_comparison" && debug.mcp_discovery_response_applied === true + ? readCounterpartyValueFlowProofBundles(debug) : null; const inheritedComparisonScope = state.session_context.comparison_scope; const inheritedComparisonProofBundles = diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 74c1359..090c509 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -1629,6 +1629,16 @@ function mergeFollowupFilters( intent === "vat_payable_confirmed_as_of_date"; const currentHasPeriod = hasExplicitPeriodWindow(merged); const previousHasPeriod = hasExplicitPeriodWindow(previous); + const currentPeriodFromForCarryover = toNonEmptyString(merged.period_from); + const currentPeriodToForCarryover = toNonEmptyString(merged.period_to); + const todayIsoForPeriodCarryover = new Date().toISOString().slice(0, 10); + const currentValueFlowPeriodDefaultsToToday = + isValueCounterpartyIntent(intent) && + hasFollowupSignal && + !hasExplicitPeriodInMessage && + !hasExplicitCurrentDateInMessage && + !currentPeriodFromForCarryover && + currentPeriodToForCarryover === todayIsoForPeriodCarryover; const currentCounterpartyExplicit = toNonEmptyString(merged.counterparty); const currentContractExplicit = toNonEmptyString(merged.contract); const currentItemExplicit = toNonEmptyString(merged.item); @@ -1717,7 +1727,7 @@ function mergeFollowupFilters( } if ( - !currentHasPeriod && + (!currentHasPeriod || currentValueFlowPeriodDefaultsToToday) && previousHasPeriod && hasFollowupSignal && !hasExplicitPeriodInMessage && diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index a687437..dbea20f 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -1158,6 +1158,10 @@ export function resolveFollowupTargetIntent( export function shouldUseNavigationTemporalCarryover(sourceIntentHint: unknown): boolean { const normalizedIntent = fallbackToNonEmptyString(sourceIntentHint); return ( + normalizedIntent === "customer_revenue_and_payments" || + normalizedIntent === "supplier_payouts_profile" || + normalizedIntent === "contract_usage_and_value" || + normalizedIntent === "counterparty_activity_lifecycle" || normalizedIntent === "inventory_on_hand_as_of_date" || normalizedIntent === "inventory_supplier_stock_overlap_as_of_date" || normalizedIntent === "inventory_purchase_provenance_for_item" || diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index cd2cf18..31ca76d 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -281,7 +281,7 @@ function valueFlowDirectionLabelRu(pilot: AssistantMcpDiscoveryPilotExecutionCon : "входящих денежных поступлений"; } -function valueFlowZeroResultConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { +function valueFlowZeroResultInsufficiencyLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { if (!hasExecutedZeroValueFlowRows(pilot)) { return null; } @@ -293,7 +293,23 @@ function valueFlowZeroResultConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecu const period = explicitDateScope(pilot); const organizationPart = organization ? ` по организации ${organization}` : ""; const periodPart = period ? ` за период ${period}` : " в проверенном окне"; - return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart}: 0 руб.; ${valueFlowDirectionLabelRu( + const direction = valueFlowDirectionLabelRu(pilot); + return `Точную сумму ${direction} по контрагенту ${counterparty}${organizationPart}${periodPart} не подтверждаю: в выполненной проверке 1С строки ${direction} не найдены; это не доказывает отсутствие операций вне доступного банковского контура.`; +} + +function valueFlowZeroResultCheckedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { + if (!hasExecutedZeroValueFlowRows(pilot)) { + return null; + } + const counterparty = firstEntityCandidate(pilot); + if (!counterparty) { + return null; + } + const organization = explicitOrganizationScope(pilot); + const period = explicitDateScope(pilot); + const organizationPart = organization ? ` по организации ${organization}` : ""; + const periodPart = period ? ` за период ${period}` : " в проверенном окне"; + return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart} строки ${valueFlowDirectionLabelRu( pilot )} не найдено.`; } @@ -312,11 +328,11 @@ function valueFlowZeroResultUnknownLine(pilot: AssistantMcpDiscoveryPilotExecuti } function valueFlowZeroResultHeadline(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { - const confirmedLine = valueFlowZeroResultConfirmedLine(pilot); - if (!confirmedLine) { + const insufficiencyLine = valueFlowZeroResultInsufficiencyLine(pilot); + if (!insufficiencyLine) { return null; } - return confirmedLine; + return insufficiencyLine; } function hasAllTimeScope(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { @@ -2126,8 +2142,6 @@ export function buildAssistantMcpDiscoveryAnswerDraft( ? [derivedValueLine] : derivedValueLine ? [derivedValueLine, ...monthlyConfirmedLines] - : valueFlowZeroResultConfirmedLine(pilot) - ? [valueFlowZeroResultConfirmedLine(pilot)!] : derivedEntityResolutionLine ? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine] : derivedMetadataLine @@ -2139,7 +2153,12 @@ export function buildAssistantMcpDiscoveryAnswerDraft( ? pilot.derived_metadata_surface.available_fields.length > 0 ? userFacingUnknowns(pilot.evidence.unknown_facts) : ["Детальный список полей этих объектов этим шагом не получен."] - : appendValueFlowZeroResultUnknown(rankedValueFlowUnknownLines(pilot), pilot); + : appendValueFlowZeroResultUnknown( + valueFlowZeroResultCheckedLine(pilot) + ? [valueFlowZeroResultCheckedLine(pilot)!, ...rankedValueFlowUnknownLines(pilot)] + : rankedValueFlowUnknownLines(pilot), + pilot + ); return { schema_version: ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index 6bce2d9..3b7fe6c 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -3217,9 +3217,28 @@ function deriveValueFlow( direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"], aggregationAxis: AssistantMcpDiscoveryAggregationAxis | null ): AssistantMcpDiscoveryDerivedValueFlow | null { - if (!result || result.error || result.matched_rows <= 0) { + if (!result || result.error) { return null; } + if (result.matched_rows <= 0) { + return { + value_flow_direction: direction, + counterparty, + period_scope: periodScope, + aggregation_axis: aggregationAxis, + rows_matched: 0, + rows_with_amount: 0, + total_amount: 0, + total_amount_human_ru: formatAmountHumanRu(0), + first_movement_date: null, + latest_movement_date: null, + coverage_limited_by_probe_limit: result.coverage_limited_by_probe_limit, + coverage_recovered_by_period_chunking: result.coverage_recovered_by_period_chunking, + period_chunking_granularity: result.period_chunking_granularity, + monthly_breakdown: [], + inference_basis: "sum_of_confirmed_1c_value_flow_rows" + }; + } let totalAmount = 0; let rowsWithAmount = 0; for (const row of result.rows) { @@ -5294,16 +5313,30 @@ function buildValueFlowConfirmedFacts( counterparty: string | null, direction: AssistantMcpDiscoveryDerivedValueFlow["value_flow_direction"] ): string[] { - if (result.error || result.matched_rows <= 0) { + if (result.error) { return []; } if (direction === "outgoing_supplier_payout") { + if (result.matched_rows <= 0) { + return [ + counterparty + ? `1C supplier-payout rows were checked for counterparty ${counterparty}: not_found` + : "1C supplier-payout rows were checked for the requested counterparty scope: not_found" + ]; + } return [ counterparty ? `1C supplier-payout rows were found for counterparty ${counterparty}` : "1C supplier-payout rows were found for the requested counterparty scope" ]; } + if (result.matched_rows <= 0) { + return [ + counterparty + ? `1C value-flow rows were checked for counterparty ${counterparty}: not_found` + : "1C value-flow rows were checked for the requested counterparty scope: not_found" + ]; + } return [ counterparty ? `1C value-flow rows were found for counterparty ${counterparty}` @@ -5521,6 +5554,14 @@ function buildValueFlowInferredFacts(derived: AssistantMcpDiscoveryDerivedValueF return []; } const facts: string[] = []; + if (derived.rows_matched <= 0) { + facts.push( + derived.value_flow_direction === "outgoing_supplier_payout" + ? "Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set" + : "Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set" + ); + return facts; + } if (derived.value_flow_direction === "outgoing_supplier_payout") { facts.push("Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows"); } else { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index dddba87..e2d6e21 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -386,6 +386,7 @@ export function resolveAssistantMcpDiscoveryEvidence( const rowsMatched = probeRowsMatched(probeResults); const rowsReceived = probeRowsReceived(probeResults); const bypassDetected = hasProbeBypass(input.plan, probeResults); + const confirmedNoMatchFact = confirmedFacts.some((fact) => /\bnot_found\b/iu.test(String(fact ?? ""))); if (bypassDetected) { pushReason(reasonCodes, "probe_result_used_primitive_outside_runtime_plan"); @@ -393,7 +394,7 @@ export function resolveAssistantMcpDiscoveryEvidence( if (input.plan.plan_status !== "allowed") { pushReason(reasonCodes, "plan_not_allowed_by_runtime"); } - if (confirmedFacts.length > 0 && rowsMatched <= 0) { + if (confirmedFacts.length > 0 && rowsMatched <= 0 && !confirmedNoMatchFact) { pushReason(reasonCodes, "confirmed_facts_without_matched_probe_rows"); } if (!sourceRowsSummary && rowsReceived > 0) { @@ -413,6 +414,11 @@ export function resolveAssistantMcpDiscoveryEvidence( coverageStatus = "full"; answerPermission = "confirmed_answer"; pushReason(reasonCodes, "confirmed_facts_with_allowed_mcp_evidence"); + } else if (confirmedFacts.length > 0 && confirmedNoMatchFact && rowsMatched <= 0 && sourceRowsSummary) { + evidenceStatus = "confirmed"; + coverageStatus = "full"; + answerPermission = "confirmed_answer"; + pushReason(reasonCodes, "confirmed_no_match_fact_with_allowed_mcp_evidence"); } else if (inferredFacts.length > 0 && rowsReceived > 0) { evidenceStatus = "inferred_only"; coverageStatus = "partial"; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 17cef2b..2fc87ef 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -284,9 +284,15 @@ function localizeLine(value: string): string { if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки входящих денежных поступлений в запрошенном срезе."; } + if (/^1C value-flow rows were checked for the requested counterparty scope: not_found$/i.test(value)) { + return "В 1С проверены входящие денежные строки в запрошенном срезе: строки не найдены."; + } if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки исходящих платежей и списаний в запрошенном срезе."; } + if (/^1C supplier-payout rows were checked for the requested counterparty scope: not_found$/i.test(value)) { + return "В 1С проверены исходящие платежи/списания в запрошенном срезе: строки не найдены."; + } const openScopeBidirectionalMatch = value.match( /^1C bidirectional value-flow rows were checked for the requested counterparty scope: incoming=(found|not_found), outgoing=(found|not_found)$/i ); @@ -319,6 +325,10 @@ function localizeLine(value: string): string { if (valueFlowMatch) { return `В 1С найдены строки входящих денежных поступлений по контрагенту ${valueFlowMatch[1]}.`; } + const valueFlowNotFoundMatch = value.match(/^1C value-flow rows were checked for counterparty\s+(.+): not_found$/i); + if (valueFlowNotFoundMatch) { + return `В 1С проверены входящие денежные строки по контрагенту ${valueFlowNotFoundMatch[1]}: строки не найдены.`; + } if (/^1C value-flow rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки входящих денежных поступлений по запрошенному контрагентскому контуру."; } @@ -340,6 +350,10 @@ function localizeLine(value: string): string { if (supplierPayoutMatch) { return `В 1С найдены строки исходящих платежей/списаний по контрагенту ${supplierPayoutMatch[1]}.`; } + const supplierPayoutNotFoundMatch = value.match(/^1C supplier-payout rows were checked for counterparty\s+(.+): not_found$/i); + if (supplierPayoutNotFoundMatch) { + return `В 1С проверены исходящие платежи/списания по контрагенту ${supplierPayoutNotFoundMatch[1]}: строки не найдены.`; + } if (/^1C supplier-payout rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки исходящих платежей/списаний по запрошенному контрагентскому контуру."; } @@ -377,6 +391,12 @@ function localizeLine(value: string): string { if (/^Counterparty supplier-payout total was calculated from confirmed 1C outgoing payment rows$/i.test(value)) { return "Сумма исходящих платежей рассчитана только по подтвержденным строкам списаний в 1С."; } + if (/^Counterparty supplier-payout not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) { + return "Нулевую сумму исходящих платежей можно трактовать только внутри этого проверенного среза 1С, где строки списаний не найдены."; + } + if (/^Counterparty incoming value-flow not_found result was treated as zero only inside the scoped checked 1C result set$/i.test(value)) { + return "Нулевую сумму входящих поступлений можно трактовать только внутри этого проверенного среза 1С, где строки поступлений не найдены."; + } if (/^Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows$/i.test(value)) { return "Нетто денежного потока рассчитано как подтвержденные входящие платежи минус подтвержденные исходящие платежи в 1С."; } @@ -1429,7 +1449,7 @@ function buildCompactBusinessOverviewReply( netAmount ? `${netDirection} ${sentenceAmount(netAmount) ?? netAmount}` : null ].filter((value): value is string => Boolean(value)); lines.push( - `По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, не аудиторское заключение и не подтвержденная чистая прибыль.` + `По подтвержденным строкам 1С ${subject} за ${periodWithoutPrefix}: ${executiveFacts.length > 0 ? executiveFacts.join(", ") : "денежные метрики не подтверждены"}; это ограниченный проверенный срез, а не финансовый аудит и не подтверждение чистой прибыли.` ); lines.push( "Интерпретация: по этому срезу видны крупные контрактные денежные потоки и заметная зависимость от нескольких крупных контрагентов, а не равномерный поток мелких продаж." @@ -1472,7 +1492,7 @@ function buildCompactBusinessOverviewReply( } if (!limitLine) { lines.push( - "Ограничение: это оценка по денежным потокам и найденным срезам 1С, не аудиторское заключение и не подтвержденная чистая прибыль." + "Ограничение: это оценка по денежным потокам и найденным срезам 1С, а не финансовый аудит и не подтверждение чистой прибыли." ); } const missingOverviewFamilies: string[] = []; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 938cfc1..9f404d9 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -855,6 +855,65 @@ function hasExactBankOperationsAddressReply( ); } +function extractExplicitDateScopeYear(value: unknown): string | null { + const source = toNonEmptyString(value); + if (!source) { + return null; + } + const match = source.match(/\b((?:19|20)\d{2})\b/u); + return match ? match[1] : null; +} + +function extractDocumentReplyDateYears(value: unknown): string[] { + const source = String(value ?? ""); + if (!source) { + return []; + } + const years = new Set(); + for (const match of source.matchAll(/\b((?:19|20)\d{2})-\d{2}-\d{2}(?:T|\b)/gu)) { + years.add(match[1]); + } + for (const match of source.matchAll(/\b\d{1,2}\.\d{1,2}\.((?:19|20)\d{2})\b/gu)) { + years.add(match[1]); + } + return [...years]; +} + +function hasDocumentListTemporalConflictWithDiscovery( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const isDocumentIntent = + detectedIntent === "list_documents_by_counterparty" || detectedIntent === "list_documents_by_contract"; + const isDocumentRecipe = + selectedRecipe === "address_documents_by_counterparty_v1" || + selectedRecipe === "address_documents_by_contract_v1"; + if (!isDocumentIntent || !isDocumentRecipe) { + return false; + } + + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const expectedYear = extractExplicitDateScopeYear(turnMeaning?.explicit_date_scope); + if (!expectedYear) { + return false; + } + + const filters = toRecordObject(input.addressRuntimeMeta?.extracted_filters); + const periodFrom = toNonEmptyString(filters?.period_from); + const periodTo = toNonEmptyString(filters?.period_to); + const hasMatchingPeriod = + Boolean(periodFrom?.startsWith(`${expectedYear}-`)) && Boolean(periodTo?.startsWith(`${expectedYear}-`)); + const replyDateYears = extractDocumentReplyDateYears(input.currentReply); + const hasOutOfScopeReplyDates = replyDateYears.some((year) => year !== expectedYear); + + return hasOutOfScopeReplyDates || (!hasMatchingPeriod && replyDateYears.length > 0); +} + function hasExactDocumentListAddressReply( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -1299,7 +1358,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( entryPoint ); const exactBankOperationsAddressReply = hasExactBankOperationsAddressReply(input, entryPoint); - const exactDocumentListAddressReply = hasExactDocumentListAddressReply(input, entryPoint); + const documentListTemporalConflictWithDiscovery = hasDocumentListTemporalConflictWithDiscovery(input, entryPoint); + const exactDocumentListAddressReply = + hasExactDocumentListAddressReply(input, entryPoint) && !documentListTemporalConflictWithDiscovery; const inventoryMarginRankingAddressReply = hasInventoryMarginRankingAddressReply(input, entryPoint); const exactInventoryPurchaseToSaleChainAddressReply = hasExactInventoryPurchaseToSaleChainAddressReply( input, @@ -1340,13 +1401,15 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !businessOverviewBoundaryCandidatePriority && !groundedValueFlowRankingCandidatePriority; const alignedFactualAddressReplyProtectsCurrent = - alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + alignedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery; const matchedFactualAddressContinuationTargetProtectsCurrent = - matchedFactualAddressContinuationTarget && !groundedValueFlowRankingCandidatePriority; + matchedFactualAddressContinuationTarget && + !groundedValueFlowRankingCandidatePriority && + !documentListTemporalConflictWithDiscovery; const fullConfirmedFactualAddressReplyProtectsCurrent = - fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + fullConfirmedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery; const exactMatchedFactualAddressReplyProtectsCurrent = - exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority; + exactMatchedFactualAddressReply && !groundedValueFlowRankingCandidatePriority && !documentListTemporalConflictWithDiscovery; const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && @@ -1383,6 +1446,9 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (evidenceLaneConflictWithDiscoveryTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override"); } + if (documentListTemporalConflictWithDiscovery) { + pushReason(reasonCodes, "mcp_discovery_response_policy_document_list_temporal_conflict_allows_candidate_override"); + } if (currentClarificationProtectsCurrentReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_current_clarification_required_reply"); } diff --git a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts index cd24a46..76f49e6 100644 --- a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts @@ -164,6 +164,28 @@ describe("address follow-up temporal regressions", () => { expect(result?.baseReasons).not.toContain("counterparty_carryover_suppressed_for_short_debt_mirror"); }); + it("keeps explicit year period for selected counterparty payout follow-up", () => { + const result = runAddressDecomposeStage("а теперь сколько заплатили?", { + previous_intent: "customer_revenue_and_payments" as const, + target_intent: "customer_revenue_and_payments" as const, + previous_filters: { + counterparty: "Группа СВК", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "counterparty" as const, + previous_anchor_value: "Группа СВК", + resolved_counterparty_from_display: true + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("customer_revenue_and_payments"); + expect(result?.filters.extracted_filters.counterparty).toBe("Группа СВК"); + expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31"); + expect(result?.baseReasons).toContain("period_from_followup_context"); + }); + it("keeps same-date inventory pivot anchored to the previous VAT date", () => { const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", { previous_intent: "vat_payable_confirmed_as_of_date", @@ -454,6 +476,29 @@ describe("address follow-up temporal regressions", () => { expect(movements?.baseReasons).toContain("counterparty_from_followup_context"); }); + it("keeps value-flow period for counterparty document evidence follow-up", () => { + const followupContext = { + previous_intent: "customer_revenue_and_payments" as const, + target_intent: "list_documents_by_counterparty" as const, + previous_filters: { + counterparty: "Группа СВК", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "counterparty" as const, + previous_anchor_value: "Группа СВК", + resolved_counterparty_from_display: true + }; + + const documents = runAddressDecomposeStage("а по документам?", followupContext); + + expect(documents?.intent.intent).toBe("list_documents_by_counterparty"); + expect(documents?.filters.extracted_filters.counterparty).toBe("Группа СВК"); + expect(documents?.filters.extracted_filters.period_from).toBe("2020-01-01"); + expect(documents?.filters.extracted_filters.period_to).toBe("2020-12-31"); + expect(documents?.baseReasons).toContain("period_from_followup_context"); + }); + it("replaces pronoun chain anchors from counterparty follow-up context", () => { const followupContext = { previous_intent: "customer_revenue_and_payments" as const, diff --git a/llm_normalizer/backend/tests/addressNavigationState.test.ts b/llm_normalizer/backend/tests/addressNavigationState.test.ts index ab39e75..299611d 100644 --- a/llm_normalizer/backend/tests/addressNavigationState.test.ts +++ b/llm_normalizer/backend/tests/addressNavigationState.test.ts @@ -220,6 +220,262 @@ describe("address navigation state", () => { expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31"); }); + it("captures resolved entity-resolution focus as fresh current discovery state", () => { + const base = createEmptyAddressNavigationState("asst-entity", "2026-04-12T10:00:00.000Z"); + const assistantItem = { + message_id: "msg-entity", + session_id: "asst-entity", + role: "assistant", + text: "В проверенном каталожном срезе 1С найден наиболее вероятный контрагент: Группа СВК.", + created_at: "2026-04-12T10:12:30.000Z", + debug: { + detected_mode: "chat", + detected_intent: "unknown", + extracted_filters: {}, + anchor_type: "unknown", + mcp_discovery_response_applied: true, + mcp_discovery_selected_chain_id: "entity_resolution", + assistant_mcp_discovery_entry_point_v1: { + turn_input: { + turn_meaning_ref: { + asked_domain_family: "entity_resolution", + asked_action_family: "search_business_entity", + explicit_entity_candidates: ["СВК"], + stale_replay_forbidden: true + } + }, + bridge: { + pilot: { + pilot_scope: "entity_resolution_search_v1", + derived_entity_resolution: { + requested_entity: "СВК", + resolution_status: "resolved", + resolved_entity: "Группа СВК" + } + } + } + }, + dialog_continuation_contract_v2: { + decision: "new_topic" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 1); + + expect(evolved.session_context.active_focus_object?.object_type).toBe("counterparty"); + expect(evolved.session_context.active_focus_object?.label).toBe("Группа СВК"); + expect(evolved.result_sets[0]?.filters.counterparty).toBe("Группа СВК"); + expect(evolved.navigation_history[0]?.target_object_id).toBe("counterparty:группа свк"); + }); + + it("captures single-direction value-flow focus before the net bundle exists", () => { + const base = createEmptyAddressNavigationState("asst-vf-single", "2026-04-12T10:00:00.000Z"); + const assistantItem = { + message_id: "msg-vf-single", + session_id: "asst-vf-single", + role: "assistant", + text: "Входящие денежные поступления по контрагенту Группа СВК за период 2020: 12 093 465 руб.", + created_at: "2026-04-12T10:13:00.000Z", + debug: { + detected_mode: "chat", + detected_intent: "unknown", + extracted_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + anchor_type: "unknown", + mcp_discovery_response_applied: true, + mcp_discovery_selected_chain_id: "value_flow", + assistant_mcp_discovery_entry_point_v1: { + turn_input: { + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + stale_replay_forbidden: true + } + }, + bridge: { + pilot: { + pilot_scope: "counterparty_value_flow_query_movements_v1", + derived_value_flow: { + value_flow_direction: "incoming_customer_revenue", + counterparty: "Группа СВК", + period_scope: "2020", + total_amount_human_ru: "12 093 465 руб." + } + } + } + }, + dialog_continuation_contract_v2: { + decision: "continue_previous" + } + } + } as any; + + const evolved = evolveAddressNavigationStateWithAssistantItem(base, assistantItem, 2); + + expect(evolved.result_sets[0]?.intent).toBe("customer_revenue_and_payments"); + expect(evolved.result_sets[0]?.filters.counterparty).toBe("Группа СВК"); + expect(evolved.session_context.active_focus_object?.label).toBe("Группа СВК"); + expect(evolved.session_context.date_scope.period_from).toBe("2020-01-01"); + expect(evolved.session_context.date_scope.period_to).toBe("2020-12-31"); + }); + + it("stores bidirectional value-flow proof bundle for adjacent evidence drilldowns", () => { + const base = createEmptyAddressNavigationState("asst-vf-bundle", "2026-04-12T10:00:00.000Z"); + const valueFlowBundle = { + counterparty: "Группа СВК", + period_scope: "2020", + incoming_customer_revenue: { + total_amount_human_ru: "12 093 465 руб." + }, + outgoing_supplier_payout: { + total_amount_human_ru: "0 руб." + }, + net_amount_human_ru: "12 093 465 руб.", + net_direction: "net_incoming" + }; + const netItem = { + message_id: "msg-vf-net", + session_id: "asst-vf-bundle", + role: "assistant", + text: "По контрагенту Группа СВК за период 2020 получили 12 093 465 руб., заплатили 0 руб.; расчетное нетто в нашу сторону: 12 093 465 руб.", + created_at: "2026-04-12T10:14:00.000Z", + debug: { + detected_mode: "chat", + detected_intent: "customer_revenue_and_payments", + extracted_filters: { + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + anchor_type: "unknown", + mcp_discovery_response_applied: true, + mcp_discovery_selected_chain_id: "value_flow_comparison", + assistant_mcp_discovery_entry_point_v1: { + turn_input: { + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + stale_replay_forbidden: true + } + }, + bridge: { + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + derived_bidirectional_value_flow: valueFlowBundle + } + } + }, + dialog_continuation_contract_v2: { + decision: "continue_previous" + } + } + } as any; + + const afterNet = evolveAddressNavigationStateWithAssistantItem(base, netItem, 5); + expect(afterNet.session_context.comparison_scope?.counterparty?.label).toBe("Группа СВК"); + expect(afterNet.session_context.comparison_scope?.proof_bundles?.counterparty_value_flow_bundle).toEqual( + valueFlowBundle + ); + + const docsItem = { + message_id: "msg-vf-docs", + session_id: "asst-vf-bundle", + role: "assistant", + text: "По документам по контрагенту Группа СВК за 2020 полный срез не подтвержден.", + created_at: "2026-04-12T10:15:00.000Z", + debug: { + detected_mode: "chat", + detected_intent: "list_documents_by_counterparty", + extracted_filters: { + counterparty: "Группа СВК", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + anchor_type: "unknown", + mcp_discovery_response_applied: true, + mcp_discovery_selected_chain_id: "document_evidence", + assistant_mcp_discovery_entry_point_v1: { + turn_input: { + turn_meaning_ref: { + asked_domain_family: "documents", + asked_action_family: "list_documents", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + stale_replay_forbidden: true + } + }, + bridge: { + pilot: { + pilot_scope: "counterparty_document_evidence_query_documents_v1" + } + } + }, + dialog_continuation_contract_v2: { + decision: "continue_previous" + } + } + } as any; + + const afterDocs = evolveAddressNavigationStateWithAssistantItem(afterNet, docsItem, 6); + expect(afterDocs.session_context.active_focus_object?.label).toBe("Группа СВК"); + expect(afterDocs.session_context.comparison_scope?.proof_bundles?.counterparty_value_flow_bundle).toEqual( + valueFlowBundle + ); + + const movementsItem = { + message_id: "msg-vf-movements", + session_id: "asst-vf-bundle", + role: "assistant", + text: "По движениям по контрагенту Группа СВК за 2020 полный срез не подтвержден.", + created_at: "2026-04-12T10:16:00.000Z", + debug: { + detected_mode: "chat", + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + counterparty: "Группа СВК", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + anchor_type: "unknown", + mcp_discovery_response_applied: true, + mcp_discovery_selected_chain_id: "movement_evidence", + assistant_mcp_discovery_entry_point_v1: { + turn_input: { + turn_meaning_ref: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + stale_replay_forbidden: true + } + }, + bridge: { + pilot: { + pilot_scope: "counterparty_movement_evidence_query_movements_v1" + } + } + }, + dialog_continuation_contract_v2: { + decision: "continue_previous" + } + } + } as any; + + const afterMovements = evolveAddressNavigationStateWithAssistantItem(afterDocs, movementsItem, 7); + expect(afterMovements.result_sets.at(-1)?.intent).toBe("bank_operations_by_counterparty"); + expect(afterMovements.result_sets.at(-1)?.type).toBe("bank_operations_list"); + expect(afterMovements.session_context.active_focus_object?.label).toBe("Группа СВК"); + expect(afterMovements.session_context.comparison_scope?.proof_bundles?.counterparty_value_flow_bundle).toEqual( + valueFlowBundle + ); + }); + it("keeps selected counterparty focus for company boundary summaries", () => { const initial = createEmptyAddressNavigationState("asst-boundary", "2026-04-12T10:00:00.000Z"); const assistantItem = { diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index 3f91ade..860883a 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -684,6 +684,27 @@ describe("assistantContinuityPolicy organization authority", () => { }); }); + it("carries value-flow temporal scope into follow-up evidence drilldowns", () => { + const filters = applyTemporalCarryoverFilters( + { + counterparty: "Группа СВК" + }, + { + as_of_date: null, + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + null, + "customer_revenue_and_payments" + ); + + expect(filters).toEqual({ + counterparty: "Группа СВК", + period_from: "2020-01-01", + period_to: "2020-12-31" + }); + }); + it("resolves inventory root pivots through one shared helper", () => { const flags = resolveInventoryFollowupPivotFlags( { intent: "inventory_on_hand_as_of_date" }, diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 9eb66d7..225caf8 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -130,15 +130,14 @@ describe("assistant MCP discovery answer adapter", () => { const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([])); const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); - const answerText = [draft.headline, ...draft.confirmed_lines, ...draft.unknown_lines].join("\n"); + const answerText = [draft.headline, ...draft.confirmed_lines, ...draft.inference_lines, ...draft.unknown_lines].join("\n"); - expect(draft.answer_mode).toBe("checked_sources_only"); + expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); expect(answerText).toContain("0 руб."); - expect(answerText).toContain("исходящих платежей/списаний не найдено"); + expect(answerText).toContain("not_found"); expect(answerText).toContain("Группа СВК"); expect(answerText).toContain("2020"); - expect(draft.next_step_line).toContain("соседних периодах"); - expect(draft.next_step_line).toContain("двусторонний денежный поток"); + expect(draft.unknown_lines.join("\n")).toContain("outside the checked period"); }); it("turns generic document evidence into a bounded document answer draft", async () => { @@ -1464,7 +1463,7 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.unknown_lines).toContain("Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot"); }); - it("renders zero-row supplier payout as a checked negative with period and counterparty", async () => { + it("renders zero-row supplier payout as a confirmed scoped zero with period and counterparty", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { asked_domain_family: "counterparty_value", @@ -1479,18 +1478,18 @@ describe("assistant MCP discovery answer adapter", () => { const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); const confirmedText = draft.confirmed_lines.join("\n"); + const inferenceText = draft.inference_lines.join("\n"); const unknownText = draft.unknown_lines.join("\n"); - expect(draft.answer_mode).toBe("checked_sources_only"); + expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); expect(draft.headline).toContain("Группа СВК"); expect(draft.headline).toContain("2020"); - expect(draft.headline).toContain("исходящих платежей"); + expect(draft.headline).toContain("0 руб."); expect(confirmedText).toContain("Группа СВК"); - expect(confirmedText).toContain("ООО Альтернатива Плюс"); - expect(confirmedText).toContain("2020"); - expect(confirmedText).toContain("не найдено"); - expect(unknownText).toContain("вне периода 2020"); - expect(unknownText).toContain("вне доступного банковского контура"); + expect(confirmedText).toContain("0 руб."); + expect(confirmedText).toContain("Граница"); + expect(inferenceText).toContain("not_found result was treated as zero"); + expect(unknownText).toContain("outside the checked period"); }); it("turns bidirectional value-flow evidence into a bounded net cash answer draft", async () => { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index c9e020b..9d830ea 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -1650,6 +1650,38 @@ describe("assistant MCP discovery pilot executor", () => { expect(String(call?.query ?? "")).toContain("СписаниеСРасчетногоСчета"); }); + it("treats zero-row supplier payout query as confirmed not-found inside checked scope", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + } + }); + const deps = buildDeps([]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_status).toBe("executed"); + expect(result.pilot_scope).toBe("counterparty_supplier_payout_query_movements_v1"); + expect(result.evidence.evidence_status).toBe("confirmed"); + expect(result.evidence.answer_permission).toBe("confirmed_answer"); + expect(result.evidence.confirmed_facts[0]).toContain("not_found"); + expect(result.reason_codes).toContain("pilot_derived_value_flow_from_confirmed_rows"); + expect(result.evidence.reason_codes).toContain("confirmed_no_match_fact_with_allowed_mcp_evidence"); + expect(result.derived_value_flow).toMatchObject({ + value_flow_direction: "outgoing_supplier_payout", + counterparty: "SVK", + period_scope: "2020", + rows_matched: 0, + rows_with_amount: 0, + total_amount: 0, + total_amount_human_ru: "0 руб." + }); + }); + it("marks value-flow coverage as limited when the probe row limit is reached", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index a47672d..e7daa70 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -1602,8 +1602,9 @@ describe("assistant MCP discovery response candidate", () => { expect(firstLine).toContain("По подтвержденным строкам 1С"); expect(firstLine).toContain("входящие 157 192 981,43 руб."); expect(firstLine).toContain("исходящие 35 439 044,74 руб."); - expect(firstLine).toContain("не аудиторское заключение"); - expect(firstLine).toContain("не подтвержденная чистая прибыль"); + expect(firstLine).toContain("не финансовый аудит"); + expect(firstLine).toContain("не подтверждение чистой прибыли"); + expect(firstLine).not.toContain("аудиторское заключение"); expect(candidate.reply_text).toContain("Интерпретация:"); expect(candidate.reply_text).toContain("Что видно в ограниченном денежном срезе"); expect(candidate.reply_text).toContain("Что не подтверждено в этом срезе"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index a9f996a..3e8295e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -753,7 +753,73 @@ describe("assistant MCP discovery response policy", () => { expect(result.reply_text).toContain("20 653 490"); expect(result.reply_text).toContain("2 129 651"); expect(result.reason_codes).toContain("mcp_discovery_response_policy_value_flow_action_conflict_allows_candidate_override"); - expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_document_list_address_reply"); + }); + + it("overrides exact document-list replies when they contradict the discovery period scope", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Контрагент: Группа СВК. Найдено документов: 19.\n" + + "1. 2021-11-10T12:00:07Z | Поступление на расчетный счет 00000000013 от 10.11.2021 12:00:07 | аналитика: Группа СВК", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "list_documents_by_counterparty", + selected_recipe: "address_documents_by_counterparty_v1", + mcp_call_status: "matched_non_empty", + response_type: "FACTUAL_LIST", + truth_mode: "confirmed", + extracted_filters: { + counterparty: "Группа СВК", + as_of_date: "2026-06-15" + }, + dialog_continuation_contract_v2: { + target_intent: "list_documents_by_counterparty" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "documents", + asked_action_family: "list_documents", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "document_evidence" + }, + data_need_graph: { + business_fact_family: "document_evidence", + action_family: "list_documents", + subject_candidates: ["Группа СВК"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "bounded_inference_only", + headline: "По документам по контрагенту Группа СВК за 2020 полный срез не подтвержден.", + confirmed_lines: [], + inference_lines: ["Подтвержденных строк документов за 2020 этим поиском не найдено."], + unknown_lines: ["Полный исторический срез вне 2020 этим поиском не подтвержден."], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("за 2020"); + expect(result.reply_text).not.toContain("2021-11-10"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_document_list_temporal_conflict_allows_candidate_override" + ); expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_document_list_address_reply"); });