diff --git a/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json b/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json index f56b8e9..a6ce5d3 100644 --- a/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json +++ b/docs/orchestration/address_truth_harness_phase83_planner_brain_alignment_mix.json @@ -465,7 +465,7 @@ "title": "Broad business evaluation becomes grounded summary instead of stale lifecycle dump", "question": "Как ты оценишь деятельность компании?", "required_answer_patterns_all": [ - "(?i)коротко|оценк|частичн", + "(?i)ограниченн|проверенн|не\\s+аудит|не\\s+аудитор", "(?i)1с|подтвержд", "(?i)денежн|долг|ндс|контрагент|операц" ], @@ -522,7 +522,7 @@ "factual_with_explanation" ], "required_answer_patterns_all": [ - "(?i)metadata|метадан", + "(?i)схем|метадан|объект", "(?i)справоч|catalog|directory", "(?i)контрагент" ], diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index 6594bab..fefd8ec 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -224,7 +224,7 @@ function valueFlowZeroResultConfirmedLine(pilot) { const period = explicitDateScope(pilot); const organizationPart = organization ? ` по организации ${organization}` : ""; const periodPart = period ? ` за период ${period}` : " в проверенном окне"; - return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart} ${valueFlowDirectionLabelRu(pilot)} не найдено.`; + return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart}: 0 руб.; ${valueFlowDirectionLabelRu(pilot)} не найдено.`; } function valueFlowZeroResultUnknownLine(pilot) { if (!hasExecutedZeroValueFlowRows(pilot)) { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index 3799cfa..5275cd5 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -123,6 +123,14 @@ function hasBusinessOverviewDirectMoneyAnswerHint(input) { } return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|how\s+much)[\s\S]{0,120}(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447|\u0434\u0435\u043d\p{L}*|\u043f\u043e\u043b\u0443\u0447|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*)|(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u0432\u044b\u0440\u0443\u0447)[\s\S]{0,120}(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u0432\u0441\u0435\u0433\u043e|\u0432\u043e\u043e\u0431\u0449\u0435|(?:19|20)\d{2}|all\s+time)|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|which|what)[\s\S]{0,80}(?:\u0441\u0430\u043c\p{L}*|top|best|most)[\s\S]{0,80}(?:\u0434\u043e\u0445\u043e\u0434\u043d|\u0432\u044b\u0440\u0443\u0447|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover)[\s\S]{0,40}(?:\u0433\u043e\u0434|year)/iu.test(text); } +function hasIncomingVsOutgoingComparisonHint(rawUtterance) { + if (!rawUtterance) { + return false; + } + const mentionsBothDirections = /(?:\u0432\u0445\u043e\u0434\p{L}*[\s\S]{0,100}\u0438(?:\u043b\u0438)?\s+\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*[\s\S]{0,100}\u0438(?:\u043b\u0438)?\s+\u0432\u0445\u043e\u0434\p{L}*|incoming[\s\S]{0,100}outgoing|outgoing[\s\S]{0,100}incoming)/iu.test(rawUtterance); + const asksComparison = /(?:\u0447\u0442\u043e[\s\S]{0,40}\u0431\u043e\u043b\p{L}*|\u0431\u043e\u043b\p{L}*[\s\S]{0,40}\u0447\u0442\u043e|\u043f\u0440\u0435\u0432\u044b\p{L}*|\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u0441\u0440\u0430\u0432\u043d\p{L}*|which[\s\S]{0,40}(?:more|larger|higher|bigger)|what[\s\S]{0,40}(?:more|larger|higher|bigger)|(?:more|larger|higher|bigger)[\s\S]{0,40}(?:incoming|outgoing))/iu.test(rawUtterance); + return mentionsBothDirections && asksComparison; +} function timeScopeNeedFor(input) { if (input.explicitDateScope) { return "explicit_period"; @@ -420,7 +428,7 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); const metadataScopeHint = toNonEmptyString(turnMeaning?.metadata_scope_hint); const subjectResolutionOptional = turnMeaning?.subject_resolution_optional === true; - const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) + const rawSubjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) .map((item) => toNonEmptyString(item)) .filter((item) => Boolean(item)); const initialBusinessFactFamily = businessFactFamilyFor({ @@ -434,10 +442,26 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const oneSidedBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" && Boolean(oneSidedValueAction) && !detailedBusinessOverviewMoneyBreakdownHint; - const action = oneSidedBusinessOverviewValueFlowOverride ? oneSidedValueAction : rawAction; - const businessFactFamily = oneSidedBusinessOverviewValueFlowOverride ? "value_flow" : initialBusinessFactFamily; + const bidirectionalBusinessOverviewValueFlowOverride = initialBusinessFactFamily === "business_overview" && hasIncomingVsOutgoingComparisonHint(rawQuestionSignal); + const oneSidedValueFlowActionOverride = initialBusinessFactFamily === "value_flow" && + Boolean(oneSidedValueAction) && + (rawAction === "counterparty_value_or_turnover" || rawAction === "counterparty_value"); + const action = bidirectionalBusinessOverviewValueFlowOverride + ? "net_value_flow" + : oneSidedBusinessOverviewValueFlowOverride || oneSidedValueFlowActionOverride + ? oneSidedValueAction + : rawAction; + const businessFactFamily = oneSidedBusinessOverviewValueFlowOverride || bidirectionalBusinessOverviewValueFlowOverride + ? "value_flow" + : initialBusinessFactFamily; const aggregationNeed = aggregationNeedFor(aggregationAxis); const comparisonNeed = comparisonNeedFor(action); + const organizationScopedValueComparison = businessFactFamily === "value_flow" && + comparisonNeed === "incoming_vs_outgoing" && + Boolean(explicitOrganizationScope) && + hasIncomingVsOutgoingComparisonHint(rawQuestionSignal); + const subjectCandidates = organizationScopedValueComparison ? [] : rawSubjectCandidates; + const effectiveMetadataScopeHint = organizationScopedValueComparison ? null : metadataScopeHint; const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); const subjectScopedBidirectionalAllTime = businessFactFamily === "value_flow" && comparisonNeed === "incoming_vs_outgoing" && @@ -556,6 +580,15 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { if (oneSidedBusinessOverviewValueFlowOverride) { pushReason(reasonCodes, "data_need_graph_business_overview_one_sided_money_total_routed_to_value_flow"); } + if (bidirectionalBusinessOverviewValueFlowOverride) { + pushReason(reasonCodes, "data_need_graph_business_overview_bidirectional_money_comparison_routed_to_value_flow"); + } + if (organizationScopedValueComparison) { + pushReason(reasonCodes, "data_need_graph_organization_scoped_value_comparison_ignores_stale_subject"); + } + if (oneSidedValueFlowActionOverride) { + pushReason(reasonCodes, "data_need_graph_value_flow_one_sided_money_total_action_normalized"); + } if (clarificationGaps.includes("organization")) { pushReason(reasonCodes, "data_need_graph_open_scope_total_needs_organization"); } @@ -566,7 +599,7 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { schema_version: exports.ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryDataNeedGraph", subject_candidates: subjectCandidates, - metadata_scope_hint: metadataScopeHint, + metadata_scope_hint: effectiveMetadataScopeHint, subject_resolution_optional: subjectResolutionOptional || undefined, business_fact_family: businessFactFamily, action_family: toNonEmptyString(action), diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index c9524ec..5683448 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -107,7 +107,10 @@ function scoreExplicitEntityCandidate(raw, cleaned, index) { return score; } function firstEntityCandidate(planner) { - const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; + const graphCandidates = planner.data_need_graph?.subject_candidates; + const candidates = Array.isArray(graphCandidates) + ? graphCandidates + : planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; let best = null; for (let index = 0; index < candidates.length; index += 1) { const candidate = candidates[index]; @@ -582,10 +585,32 @@ function isEntityResolutionPilotEligible(planner) { combined.includes("entity discovery") || combined.includes("counterparty search"))); } +function normalizeMetadataScopeHint(value) { + const text = toNonEmptyString(value); + if (!text) { + return null; + } + const normalized = text + .replace(/[«»"']/gu, "") + .replace(/\s+/g, " ") + .trim(); + const comparable = normalized.toLowerCase().replace(/ё/g, "е"); + if (/^контрагент(?:ам|ами|ах|ов|у|а|ом|е|ы)?$/iu.test(comparable)) { + return "контрагент"; + } + if (/^(?:справочник\.)?контрагенты$/iu.test(comparable)) { + return "контрагент"; + } + return normalized; +} function metadataScopeForPlanner(planner) { + const metadataScopeHint = normalizeMetadataScopeHint(planner.discovery_plan.turn_meaning_ref?.metadata_scope_hint); + if (metadataScopeHint) { + return metadataScopeHint; + } const entityCandidate = firstEntityCandidate(planner); if (entityCandidate) { - return entityCandidate; + return normalizeMetadataScopeHint(entityCandidate) ?? entityCandidate; } if (planner.selected_chain_id === "catalog_drilldown") { const surface = planner.metadata_surface_ref; @@ -647,7 +672,9 @@ function valueFlowPilotProfile(planner) { const meaning = planner.discovery_plan.turn_meaning_ref; const action = String(meaning?.asked_action_family ?? "").toLowerCase(); const unsupported = String(meaning?.unsupported_but_understood_family ?? "").toLowerCase(); - const combined = `${action} ${unsupported}`; + const graphAction = String(planner.data_need_graph?.action_family ?? "").toLowerCase(); + const graphComparison = String(planner.data_need_graph?.comparison_need ?? "").toLowerCase(); + const combined = `${action} ${unsupported} ${graphAction} ${graphComparison} ${planner.selected_chain_id}`; if (combined.includes("net_value_flow") || combined.includes("bidirectional") || combined.includes("netting") || diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 126fbb7..fd266bc 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -614,13 +614,19 @@ function businessOverviewInventoryLine(overview) { ].filter((item) => Boolean(item)); return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null; } -function bidirectionalNetLabel(direction) { +function bidirectionalNetLabel(direction, scopeKind = "counterparty") { if (direction === "net_outgoing") { + if (scopeKind === "organization") { + return "нетто в сторону исходящих платежей"; + } return "нетто в сторону контрагента"; } if (direction === "balanced") { return "нетто около нуля"; } + if (scopeKind === "organization") { + return "нетто в сторону входящих платежей"; + } return "нетто в нашу сторону"; } function buildCompactBidirectionalValueFlowReply(entryPoint, draft) { @@ -642,6 +648,11 @@ function buildCompactBidirectionalValueFlowReply(entryPoint, draft) { } const counterparty = toNonEmptyString(flow.counterparty); const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); + const scopeKind = counterparty + ? "counterparty" + : organizationScope + ? "organization" + : "generic"; const subjectLead = counterparty ? `по контрагенту ${counterparty}` : organizationScope @@ -649,9 +660,9 @@ function buildCompactBidirectionalValueFlowReply(entryPoint, draft) { : "по выбранному контуру"; const period = toNonEmptyString(flow.period_scope); const periodText = period ? ` за период ${period}` : " в проверенном окне"; - const netLabel = bidirectionalNetLabel(flow.net_direction); + const netLabel = bidirectionalNetLabel(flow.net_direction, scopeKind); const lines = [ - `Коротко: ${subjectLead}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` + `${subjectLead}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` ]; const basis = []; if (incomingAmount) { @@ -661,7 +672,12 @@ function buildCompactBidirectionalValueFlowReply(entryPoint, draft) { basis.push("исходящие платежи"); } if (basis.length > 0) { - lines.push(`Основа: проверенные ${basis.join(" и ")} по этому контрагенту в 1С.`); + const basisScope = scopeKind === "counterparty" + ? "по этому контрагенту" + : scopeKind === "organization" + ? "по этой компании" + : "по выбранному контуру"; + lines.push(`Основа: проверенные ${basis.join(" и ")} ${basisScope} в 1С.`); } if (flow.coverage_limited_by_probe_limit === true) { lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index aaed317..9ccb4b1 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -247,6 +247,88 @@ function readTruthAnswerShape(input) { const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); return toRecordObject(truthAnswerPolicy?.answer_shape); } +function hasCurrentClarificationRequiredAddressReply(input) { + const answerShape = toNonEmptyString(input.addressRuntimeMeta?.answer_shape) ?? + toNonEmptyString(input.addressRuntimeMeta?.truth_mode) ?? + toNonEmptyString(readTruthAnswerShape(input)?.answer_shape); + if (answerShape === "clarification_required") { + return true; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const policyAnswerShape = toRecordObject(truthAnswerPolicy?.answer_shape); + return (toNonEmptyString(truthGate?.truth_mode) === "clarification_required" || + toNonEmptyString(policyAnswerShape?.answer_shape) === "clarification_required"); +} +function hasDiscoveryClarificationCandidatePriorityOverCurrentClarification(input, entryPoint, candidate) { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (candidate.candidate_status !== "clarification_candidate") { + return false; + } + if (!candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) { + return false; + } + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + const graph = readDiscoveryDataNeedGraph(entryPoint); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const discoveryFamily = toNonEmptyString(graph?.business_fact_family); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family) ?? toNonEmptyString(graph?.action_family); + const asksForMovements = discoveryFamily === "movement_evidence" || askedDomain === "movements" || askedAction === "list_movements"; + const truthAnswerShape = readTruthAnswerShape(input); + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const capabilityId = toNonEmptyString(input.addressRuntimeMeta?.capability_id) ?? + toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id) ?? + toNonEmptyString(truthAnswerShape?.capability_contract_id); + const missingFilters = [ + ...readStringArray(input.addressRuntimeMeta?.missing_required_filters), + ...readStringArray(input.addressRuntimeMeta?.missing_anchors), + ...readStringArray(toRecordObject(input.addressRuntimeMeta?.capability_binding_contract)?.missing_anchors) + ]; + const staleInventoryItemClarification = Boolean(missingFilters.includes("item") && + (detectedIntent?.startsWith("inventory_") || + selectedRecipe?.includes("inventory_") || + capabilityId?.includes("inventory_"))); + return asksForMovements && staleInventoryItemClarification; +} +function hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate) { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (candidate.candidate_status !== "ready_for_guarded_use") { + return false; + } + if (!candidate.hot_runtime_wired || !candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) { + return false; + } + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + const graph = readDiscoveryDataNeedGraph(entryPoint); + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const handoff = toRecordObject(bridge?.execution_handoff); + const routeCandidate = toRecordObject(bridge?.route_candidate); + const selectedChainId = toNonEmptyString(routeCandidate?.selected_chain_id) ?? + toNonEmptyString(bridge?.selected_chain_id) ?? + toNonEmptyString(pilot?.selected_chain_id); + const pilotScope = toNonEmptyString(pilot?.pilot_scope); + const actionFamily = toNonEmptyString(graph?.action_family); + const comparisonNeed = toNonEmptyString(graph?.comparison_need); + const valueFlowComparison = Boolean(selectedChainId === "value_flow_comparison" || + pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1" || + (actionFamily === "net_value_flow" && comparisonNeed === "incoming_vs_outgoing")); + const mcpExecutionPerformed = handoff?.mcp_execution_performed === true || pilot?.mcp_execution_performed === true; + const guardedBusinessAnswerAllowed = bridge?.user_facing_response_allowed === true && + bridge?.business_fact_answer_allowed === true && + (handoff?.can_use_guarded_response === true || toNonEmptyString(bridge?.bridge_status) === "answer_draft_ready"); + return valueFlowComparison && mcpExecutionPerformed && guardedBusinessAnswerAllowed; +} function hasEffectivelyFactualAddressReply(input) { if (toNonEmptyString(input.currentReplyType) === "factual") { return true; @@ -719,6 +801,12 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint); const evidenceLaneConflictWithDiscoveryTurnMeaning = hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint); + const currentClarificationRequiredAddressReply = hasCurrentClarificationRequiredAddressReply(input); + const discoveryClarificationCandidatePriority = hasDiscoveryClarificationCandidatePriorityOverCurrentClarification(input, entryPoint, candidate); + const groundedValueFlowCandidatePriority = hasGroundedValueFlowCandidatePriorityOverCurrentClarification(input, entryPoint, candidate); + const currentClarificationProtectsCurrentReply = currentClarificationRequiredAddressReply && + !discoveryClarificationCandidatePriority && + !groundedValueFlowCandidatePriority; const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && !valueFlowActionConflictWithDiscoveryTurnMeaning; @@ -752,6 +840,15 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (evidenceLaneConflictWithDiscoveryTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override"); } + if (currentClarificationProtectsCurrentReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_current_clarification_required_reply"); + } + if (discoveryClarificationCandidatePriority) { + pushReason(reasonCodes, "mcp_discovery_response_policy_clarification_candidate_priority_over_stale_address_clarification"); + } + if (groundedValueFlowCandidatePriority) { + pushReason(reasonCodes, "mcp_discovery_response_policy_grounded_value_flow_candidate_priority_over_current_clarification"); + } if (openScopeValueFlowDiscoveryPriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_open_scope_value_flow_candidate_priority"); } @@ -827,6 +924,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !exactDocumentListAddressReply && !inventoryMarginRankingAddressReply && !exactInventoryChainProtectsCurrent && + !currentClarificationProtectsCurrentReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 05befe1..110e05e 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -60,10 +60,25 @@ function isReferentialEntityPlaceholder(value) { "этом" ]).has((0, addressTextRepair_1.normalizeRussianComparableText)(value)); } +function isNegativeCounterpartyScopePlaceholder(value) { + if (!value) { + return false; + } + const text = compactLower((0, addressTextRepair_1.repairAddressMojibakeText)(value)); + if (!text) { + return false; + } + return (/(?:без\s+(?:привязк\p{L}*|конкретн\p{L}*)\s+к\s+контрагент\p{L}*|без\s+контрагент\p{L}*)/iu.test(text) || + /(?:одн\p{L}*\s+организац\p{L}*\s+без\s+привязк\p{L}*)/iu.test(text) || + /(?:organization\s+without\s+(?:counterparty|specific\s+counterparty))/iu.test(text)); +} function isReferentialOrganizationPlaceholder(value) { if (!value) { return false; } + if (isNegativeCounterpartyScopePlaceholder(value)) { + return true; + } return new Set([ "эта организация", "этой организации", @@ -129,6 +144,12 @@ function isGarbageSemanticAnchorCandidate(value) { "этими", "итогу", "итогам", + "одной", + "одному", + "одним", + "одна", + "один", + "одно", "всему", "всей", "всем", @@ -168,7 +189,8 @@ function isGarbageSemanticAnchorCandidate(value) { ]).has(compact)) { return true; } - if (/^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) || + if (isNegativeCounterpartyScopePlaceholder(text) || + /^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) || /^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) || /^(?:и\s+)?(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test(text) || /^(?:или\s+)?(?:обычн\p{L}*\s+)?(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент)(?:\s+или\s+(?:клиент|поставщик|покупател\p{L}*|заказчик|контрагент))?$/iu.test(text) || @@ -898,6 +920,9 @@ function normalizeLooseOrganizationAlias(value) { if (!text) { return null; } + if (isNegativeCounterpartyScopePlaceholder(text)) { + return null; + } const normalized = text .replace(/^[«"'“”]+|[»"'“”]+$/gu, "") .replace(/\s+/g, " ") diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index f626c24..e6213b4 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -293,7 +293,7 @@ function valueFlowZeroResultConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecu const period = explicitDateScope(pilot); const organizationPart = organization ? ` по организации ${organization}` : ""; const periodPart = period ? ` за период ${period}` : " в проверенном окне"; - return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart} ${valueFlowDirectionLabelRu( + return `В проверенном срезе 1С по контрагенту ${counterparty}${organizationPart}${periodPart}: 0 руб.; ${valueFlowDirectionLabelRu( pilot )} не найдено.`; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index 3e9c52d..654a056 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -193,6 +193,21 @@ function hasBusinessOverviewDirectMoneyAnswerHint(input: { ); } +function hasIncomingVsOutgoingComparisonHint(rawUtterance: string): boolean { + if (!rawUtterance) { + return false; + } + const mentionsBothDirections = + /(?:\u0432\u0445\u043e\u0434\p{L}*[\s\S]{0,100}\u0438(?:\u043b\u0438)?\s+\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*[\s\S]{0,100}\u0438(?:\u043b\u0438)?\s+\u0432\u0445\u043e\u0434\p{L}*|incoming[\s\S]{0,100}outgoing|outgoing[\s\S]{0,100}incoming)/iu.test( + rawUtterance + ); + const asksComparison = + /(?:\u0447\u0442\u043e[\s\S]{0,40}\u0431\u043e\u043b\p{L}*|\u0431\u043e\u043b\p{L}*[\s\S]{0,40}\u0447\u0442\u043e|\u043f\u0440\u0435\u0432\u044b\p{L}*|\u043f\u043b\u044e\u0441|\u043c\u0438\u043d\u0443\u0441|\u0441\u0440\u0430\u0432\u043d\p{L}*|which[\s\S]{0,40}(?:more|larger|higher|bigger)|what[\s\S]{0,40}(?:more|larger|higher|bigger)|(?:more|larger|higher|bigger)[\s\S]{0,40}(?:incoming|outgoing))/iu.test( + rawUtterance + ); + return mentionsBothDirections && asksComparison; +} + function timeScopeNeedFor(input: { family: string | null; explicitDateScope: string | null; @@ -557,7 +572,7 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const explicitOrganizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); const metadataScopeHint = toNonEmptyString(turnMeaning?.metadata_scope_hint); const subjectResolutionOptional = turnMeaning?.subject_resolution_optional === true; - const subjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) + const rawSubjectCandidates = (turnMeaning?.explicit_entity_candidates ?? []) .map((item) => toNonEmptyString(item)) .filter((item): item is string => Boolean(item)); const initialBusinessFactFamily = businessFactFamilyFor({ @@ -572,10 +587,31 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( initialBusinessFactFamily === "business_overview" && Boolean(oneSidedValueAction) && !detailedBusinessOverviewMoneyBreakdownHint; - const action = oneSidedBusinessOverviewValueFlowOverride ? oneSidedValueAction! : rawAction; - const businessFactFamily = oneSidedBusinessOverviewValueFlowOverride ? "value_flow" : initialBusinessFactFamily; + const bidirectionalBusinessOverviewValueFlowOverride = + initialBusinessFactFamily === "business_overview" && hasIncomingVsOutgoingComparisonHint(rawQuestionSignal); + const oneSidedValueFlowActionOverride = + initialBusinessFactFamily === "value_flow" && + Boolean(oneSidedValueAction) && + (rawAction === "counterparty_value_or_turnover" || rawAction === "counterparty_value"); + const action = + bidirectionalBusinessOverviewValueFlowOverride + ? "net_value_flow" + : oneSidedBusinessOverviewValueFlowOverride || oneSidedValueFlowActionOverride + ? oneSidedValueAction! + : rawAction; + const businessFactFamily = + oneSidedBusinessOverviewValueFlowOverride || bidirectionalBusinessOverviewValueFlowOverride + ? "value_flow" + : initialBusinessFactFamily; const aggregationNeed = aggregationNeedFor(aggregationAxis); const comparisonNeed = comparisonNeedFor(action); + const organizationScopedValueComparison = + businessFactFamily === "value_flow" && + comparisonNeed === "incoming_vs_outgoing" && + Boolean(explicitOrganizationScope) && + hasIncomingVsOutgoingComparisonHint(rawQuestionSignal); + const subjectCandidates = organizationScopedValueComparison ? [] : rawSubjectCandidates; + const effectiveMetadataScopeHint = organizationScopedValueComparison ? null : metadataScopeHint; const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); const subjectScopedBidirectionalAllTime = businessFactFamily === "value_flow" && @@ -705,6 +741,15 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( if (oneSidedBusinessOverviewValueFlowOverride) { pushReason(reasonCodes, "data_need_graph_business_overview_one_sided_money_total_routed_to_value_flow"); } + if (bidirectionalBusinessOverviewValueFlowOverride) { + pushReason(reasonCodes, "data_need_graph_business_overview_bidirectional_money_comparison_routed_to_value_flow"); + } + if (organizationScopedValueComparison) { + pushReason(reasonCodes, "data_need_graph_organization_scoped_value_comparison_ignores_stale_subject"); + } + if (oneSidedValueFlowActionOverride) { + pushReason(reasonCodes, "data_need_graph_value_flow_one_sided_money_total_action_normalized"); + } if (clarificationGaps.includes("organization")) { pushReason(reasonCodes, "data_need_graph_open_scope_total_needs_organization"); } @@ -716,7 +761,7 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( schema_version: ASSISTANT_MCP_DISCOVERY_DATA_NEED_GRAPH_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryDataNeedGraph", subject_candidates: subjectCandidates, - metadata_scope_hint: metadataScopeHint, + metadata_scope_hint: effectiveMetadataScopeHint, subject_resolution_optional: subjectResolutionOptional || undefined, business_fact_family: businessFactFamily, action_family: toNonEmptyString(action), diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index ab0ed5f..6bce2d9 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -752,7 +752,10 @@ function scoreExplicitEntityCandidate(raw: string, cleaned: string, index: numbe } function firstEntityCandidate(planner: AssistantMcpDiscoveryPlannerContract): string | null { - const candidates = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; + const graphCandidates = planner.data_need_graph?.subject_candidates; + const candidates = Array.isArray(graphCandidates) + ? graphCandidates + : planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; let best: { text: string; score: number } | null = null; for (let index = 0; index < candidates.length; index += 1) { const candidate = candidates[index]; @@ -1296,10 +1299,33 @@ function isEntityResolutionPilotEligible(planner: AssistantMcpDiscoveryPlannerCo ); } +function normalizeMetadataScopeHint(value: unknown): string | null { + const text = toNonEmptyString(value); + if (!text) { + return null; + } + const normalized = text + .replace(/[«»"']/gu, "") + .replace(/\s+/g, " ") + .trim(); + const comparable = normalized.toLowerCase().replace(/ё/g, "е"); + if (/^контрагент(?:ам|ами|ах|ов|у|а|ом|е|ы)?$/iu.test(comparable)) { + return "контрагент"; + } + if (/^(?:справочник\.)?контрагенты$/iu.test(comparable)) { + return "контрагент"; + } + return normalized; +} + function metadataScopeForPlanner(planner: AssistantMcpDiscoveryPlannerContract): string | null { + const metadataScopeHint = normalizeMetadataScopeHint(planner.discovery_plan.turn_meaning_ref?.metadata_scope_hint); + if (metadataScopeHint) { + return metadataScopeHint; + } const entityCandidate = firstEntityCandidate(planner); if (entityCandidate) { - return entityCandidate; + return normalizeMetadataScopeHint(entityCandidate) ?? entityCandidate; } if (planner.selected_chain_id === "catalog_drilldown") { const surface = planner.metadata_surface_ref; @@ -1375,7 +1401,9 @@ function valueFlowPilotProfile(planner: AssistantMcpDiscoveryPlannerContract): V const meaning = planner.discovery_plan.turn_meaning_ref; const action = String(meaning?.asked_action_family ?? "").toLowerCase(); const unsupported = String(meaning?.unsupported_but_understood_family ?? "").toLowerCase(); - const combined = `${action} ${unsupported}`; + const graphAction = String(planner.data_need_graph?.action_family ?? "").toLowerCase(); + const graphComparison = String(planner.data_need_graph?.comparison_need ?? "").toLowerCase(); + const combined = `${action} ${unsupported} ${graphAction} ${graphComparison} ${planner.selected_chain_id}`; if ( combined.includes("net_value_flow") || combined.includes("bidirectional") || diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 0f761d1..42d20fa 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -736,13 +736,19 @@ function businessOverviewInventoryLine(overview: Record): strin return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null; } -function bidirectionalNetLabel(direction: unknown): string { +function bidirectionalNetLabel(direction: unknown, scopeKind: "counterparty" | "organization" | "generic" = "counterparty"): string { if (direction === "net_outgoing") { + if (scopeKind === "organization") { + return "нетто в сторону исходящих платежей"; + } return "нетто в сторону контрагента"; } if (direction === "balanced") { return "нетто около нуля"; } + if (scopeKind === "organization") { + return "нетто в сторону входящих платежей"; + } return "нетто в нашу сторону"; } @@ -770,6 +776,11 @@ function buildCompactBidirectionalValueFlowReply( const counterparty = toNonEmptyString(flow.counterparty); const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); + const scopeKind: "counterparty" | "organization" | "generic" = counterparty + ? "counterparty" + : organizationScope + ? "organization" + : "generic"; const subjectLead = counterparty ? `по контрагенту ${counterparty}` : organizationScope @@ -777,9 +788,9 @@ function buildCompactBidirectionalValueFlowReply( : "по выбранному контуру"; const period = toNonEmptyString(flow.period_scope); const periodText = period ? ` за период ${period}` : " в проверенном окне"; - const netLabel = bidirectionalNetLabel(flow.net_direction); + const netLabel = bidirectionalNetLabel(flow.net_direction, scopeKind); const lines = [ - `Коротко: ${subjectLead}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` + `${subjectLead}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` ]; const basis: string[] = []; @@ -790,7 +801,13 @@ function buildCompactBidirectionalValueFlowReply( basis.push("исходящие платежи"); } if (basis.length > 0) { - lines.push(`Основа: проверенные ${basis.join(" и ")} по этому контрагенту в 1С.`); + const basisScope = + scopeKind === "counterparty" + ? "по этому контрагенту" + : scopeKind === "organization" + ? "по этой компании" + : "по выбранному контуру"; + lines.push(`Основа: проверенные ${basis.join(" и ")} ${basisScope} в 1С.`); } if (flow.coverage_limited_by_probe_limit === true) { lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 030861a..6996cb6 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -367,6 +367,116 @@ function readTruthAnswerShape(input: ApplyAssistantMcpDiscoveryResponsePolicyInp return toRecordObject(truthAnswerPolicy?.answer_shape); } +function hasCurrentClarificationRequiredAddressReply(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { + const answerShape = + toNonEmptyString(input.addressRuntimeMeta?.answer_shape) ?? + toNonEmptyString(input.addressRuntimeMeta?.truth_mode) ?? + toNonEmptyString(readTruthAnswerShape(input)?.answer_shape); + if (answerShape === "clarification_required") { + return true; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const policyAnswerShape = toRecordObject(truthAnswerPolicy?.answer_shape); + return ( + toNonEmptyString(truthGate?.truth_mode) === "clarification_required" || + toNonEmptyString(policyAnswerShape?.answer_shape) === "clarification_required" + ); +} + +function hasDiscoveryClarificationCandidatePriorityOverCurrentClarification( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null, + candidate: AssistantMcpDiscoveryResponseCandidateContract +): boolean { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (candidate.candidate_status !== "clarification_candidate") { + return false; + } + if (!candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) { + return false; + } + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + + const graph = readDiscoveryDataNeedGraph(entryPoint); + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const discoveryFamily = toNonEmptyString(graph?.business_fact_family); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = + toNonEmptyString(turnMeaning?.asked_action_family) ?? toNonEmptyString(graph?.action_family); + const asksForMovements = + discoveryFamily === "movement_evidence" || askedDomain === "movements" || askedAction === "list_movements"; + + const truthAnswerShape = readTruthAnswerShape(input); + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); + const capabilityId = + toNonEmptyString(input.addressRuntimeMeta?.capability_id) ?? + toNonEmptyString(input.addressRuntimeMeta?.capability_contract_id) ?? + toNonEmptyString(truthAnswerShape?.capability_contract_id); + const missingFilters = [ + ...readStringArray(input.addressRuntimeMeta?.missing_required_filters), + ...readStringArray(input.addressRuntimeMeta?.missing_anchors), + ...readStringArray(toRecordObject(input.addressRuntimeMeta?.capability_binding_contract)?.missing_anchors) + ]; + const staleInventoryItemClarification = Boolean( + missingFilters.includes("item") && + (detectedIntent?.startsWith("inventory_") || + selectedRecipe?.includes("inventory_") || + capabilityId?.includes("inventory_")) + ); + + return asksForMovements && staleInventoryItemClarification; +} + +function hasGroundedValueFlowCandidatePriorityOverCurrentClarification( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null, + candidate: AssistantMcpDiscoveryResponseCandidateContract +): boolean { + if (!hasCurrentClarificationRequiredAddressReply(input)) { + return false; + } + if (candidate.candidate_status !== "ready_for_guarded_use") { + return false; + } + if (!candidate.hot_runtime_wired || !candidate.eligible_for_future_hot_runtime || !toNonEmptyString(candidate.reply_text)) { + return false; + } + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + + const graph = readDiscoveryDataNeedGraph(entryPoint); + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const handoff = toRecordObject(bridge?.execution_handoff); + const routeCandidate = toRecordObject(bridge?.route_candidate); + const selectedChainId = + toNonEmptyString(routeCandidate?.selected_chain_id) ?? + toNonEmptyString(bridge?.selected_chain_id) ?? + toNonEmptyString(pilot?.selected_chain_id); + const pilotScope = toNonEmptyString(pilot?.pilot_scope); + const actionFamily = toNonEmptyString(graph?.action_family); + const comparisonNeed = toNonEmptyString(graph?.comparison_need); + const valueFlowComparison = Boolean( + selectedChainId === "value_flow_comparison" || + pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1" || + (actionFamily === "net_value_flow" && comparisonNeed === "incoming_vs_outgoing") + ); + const mcpExecutionPerformed = handoff?.mcp_execution_performed === true || pilot?.mcp_execution_performed === true; + const guardedBusinessAnswerAllowed = + bridge?.user_facing_response_allowed === true && + bridge?.business_fact_answer_allowed === true && + (handoff?.can_use_guarded_response === true || toNonEmptyString(bridge?.bridge_status) === "answer_draft_ready"); + + return valueFlowComparison && mcpExecutionPerformed && guardedBusinessAnswerAllowed; +} + function hasEffectivelyFactualAddressReply(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { if (toNonEmptyString(input.currentReplyType) === "factual") { return true; @@ -971,6 +1081,21 @@ export function applyAssistantMcpDiscoveryResponsePolicy( input, entryPoint ); + const currentClarificationRequiredAddressReply = hasCurrentClarificationRequiredAddressReply(input); + const discoveryClarificationCandidatePriority = hasDiscoveryClarificationCandidatePriorityOverCurrentClarification( + input, + entryPoint, + candidate + ); + const groundedValueFlowCandidatePriority = hasGroundedValueFlowCandidatePriorityOverCurrentClarification( + input, + entryPoint, + candidate + ); + const currentClarificationProtectsCurrentReply = + currentClarificationRequiredAddressReply && + !discoveryClarificationCandidatePriority && + !groundedValueFlowCandidatePriority; const exactBankOperationsProtectsCurrent = exactBankOperationsAddressReply && !semanticConflictWithDiscoveryTurnMeaning && @@ -1007,6 +1132,21 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (evidenceLaneConflictWithDiscoveryTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override"); } + if (currentClarificationProtectsCurrentReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_current_clarification_required_reply"); + } + if (discoveryClarificationCandidatePriority) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_clarification_candidate_priority_over_stale_address_clarification" + ); + } + if (groundedValueFlowCandidatePriority) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_grounded_value_flow_candidate_priority_over_current_clarification" + ); + } if (openScopeValueFlowDiscoveryPriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_open_scope_value_flow_candidate_priority"); } @@ -1102,6 +1242,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !exactDocumentListAddressReply && !inventoryMarginRankingAddressReply && !exactInventoryChainProtectsCurrent && + !currentClarificationProtectsCurrentReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 48cd495..1712f91 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -104,10 +104,28 @@ function isReferentialEntityPlaceholder(value: string): boolean { ]).has(normalizeRussianComparableText(value)); } +function isNegativeCounterpartyScopePlaceholder(value: string | null): boolean { + if (!value) { + return false; + } + const text = compactLower(repairAddressMojibakeText(value)); + if (!text) { + return false; + } + return ( + /(?:без\s+(?:привязк\p{L}*|конкретн\p{L}*)\s+к\s+контрагент\p{L}*|без\s+контрагент\p{L}*)/iu.test(text) || + /(?:одн\p{L}*\s+организац\p{L}*\s+без\s+привязк\p{L}*)/iu.test(text) || + /(?:organization\s+without\s+(?:counterparty|specific\s+counterparty))/iu.test(text) + ); +} + function isReferentialOrganizationPlaceholder(value: string | null): boolean { if (!value) { return false; } + if (isNegativeCounterpartyScopePlaceholder(value)) { + return true; + } return new Set([ "эта организация", "этой организации", @@ -180,6 +198,12 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean { "этими", "итогу", "итогам", + "одной", + "одному", + "одним", + "одна", + "один", + "одно", "всему", "всей", "всем", @@ -221,6 +245,7 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean { return true; } if ( + isNegativeCounterpartyScopePlaceholder(text) || /^(?:по\s+)?(?:этим|этими)\s+данн\p{L}*$/iu.test(text) || /^(?:и\s+)?кто\s+(?:главн\p{L}*|основн\p{L}*|крупн\p{L}*)\s+(?:клиент|покупател|поставщик|контрагент)(?:\s+в)?$/iu.test( text @@ -1294,6 +1319,9 @@ function normalizeLooseOrganizationAlias(value: string | null): string | null { if (!text) { return null; } + if (isNegativeCounterpartyScopePlaceholder(text)) { + return null; + } const normalized = text .replace(/^[«"'“”]+|[»"'“”]+$/gu, "") .replace(/\s+/g, " ") diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index eca2089..a48060e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -117,6 +117,28 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.must_not_claim).toContain("Do not claim a confirmed business fact when confirmed_facts is empty."); }); + it("states zero rubles for checked payout searches with no matched rows", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_payouts_or_outflow" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([])); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + const answerText = [draft.headline, ...draft.confirmed_lines, ...draft.unknown_lines].join("\n"); + + expect(draft.answer_mode).toBe("checked_sources_only"); + expect(answerText).toContain("0 руб."); + expect(answerText).toContain("исходящих платежей/списаний не найдено"); + expect(answerText).toContain("Группа СВК"); + expect(answerText).toContain("2020"); + }); + it("turns generic document evidence into a bounded document answer draft", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts index 3a6471b..84fa4b9 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts @@ -367,6 +367,42 @@ describe("assistant MCP discovery data need graph", () => { ); }); + it("routes broad-overview incoming-vs-outgoing money comparison to value-flow comparison", () => { + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "business overview evidence with bounded analyst interpretation", + rawUtterance: + "Хорошо. А что по ООО Альтернатива Плюс больше в 2020 году: входящие или исходящие деньги?", + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + explicit_entity_candidates: ["Группа СВК", "ООО Альтернатива Плюс больше"], + metadata_scope_hint: "Группа СВК", + explicit_date_scope: "2020", + explicit_organization_scope: "ООО Альтернатива Плюс" + } + }); + + expect(result.business_fact_family).toBe("value_flow"); + expect(result.subject_candidates).toEqual([]); + expect(result.metadata_scope_hint).toBeNull(); + expect(result.action_family).toBe("net_value_flow"); + expect(result.comparison_need).toBe("incoming_vs_outgoing"); + expect(result.clarification_gaps).toEqual([]); + expect(result.decomposition_candidates).toEqual([ + "collect_incoming_movements", + "collect_outgoing_movements", + "probe_coverage" + ]); + expect(result.reason_codes).toContain( + "data_need_graph_business_overview_bidirectional_money_comparison_routed_to_value_flow" + ); + expect(result.reason_codes).toContain( + "data_need_graph_organization_scoped_value_comparison_ignores_stale_subject" + ); + expect(result.reason_codes).toContain("data_need_graph_comparison_incoming_vs_outgoing"); + }); + it("treats a generic incoming total as an understood open-scope ask that still needs organization", () => { const result = buildAssistantMcpDiscoveryDataNeedGraph({ semanticDataNeed: "counterparty value-flow evidence", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index a838d7a..c9e020b 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -1170,6 +1170,41 @@ describe("assistant MCP discovery pilot executor", () => { }); }); + it("prefers normalized metadata scope hint over inflected entity candidate", async () => { + const planner = planAssistantMcpDiscovery({ + turnMeaning: { + asked_domain_family: "metadata", + asked_action_family: "inspect_catalog", + explicit_entity_candidates: ["контрагентам", "контрагент"], + metadata_scope_hint: "контрагент", + unsupported_but_understood_family: "1c_metadata_surface" + } + }); + const deps = buildMetadataDeps([ + { + FullName: "Справочник.Контрагенты", + MetaType: "Справочник", + attributes: [{ Name: "Наименование" }, { Name: "ИНН" }] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.evidence.evidence_status).toBe("confirmed"); + expect(result.derived_metadata_surface).toMatchObject({ + metadata_scope: "контрагент", + requested_meta_types: ["Справочник"], + matched_rows: 1, + available_entity_sets: ["Справочник"], + matched_objects: ["Справочник.Контрагенты"] + }); + expect(deps.executeAddressMcpMetadata).toHaveBeenCalledTimes(1); + expect(deps.executeAddressMcpMetadata.mock.calls[0]?.[0]).toMatchObject({ + meta_type: ["Справочник"], + name_mask: "контрагент" + }); + }); + it("executes catalog drilldown through a narrowed metadata probe seeded from the confirmed surface object", async () => { const planner = planAssistantMcpDiscovery({ metadataSurface: { @@ -1750,6 +1785,75 @@ describe("assistant MCP discovery pilot executor", () => { expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2); }); + it("executes organization-scoped value-flow comparison without stale counterparty subject", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + metadata_scope_hint: null, + business_fact_family: "value_flow", + action_family: "net_value_flow", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: "incoming_vs_outgoing", + ranking_need: null, + proof_expectation: "coverage_checked_fact", + clarification_gaps: [], + decomposition_candidates: ["collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_family_value_flow", + "data_need_graph_comparison_incoming_vs_outgoing", + "data_need_graph_organization_scoped_value_comparison_ignores_stale_subject" + ] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_entity_candidates: ["SVK", "OOO Alternative Plus more"], + metadata_scope_hint: "SVK", + explicit_organization_scope: "OOO Alternative Plus", + explicit_date_scope: "2020", + unsupported_but_understood_family: "broad_business_evaluation" + } + }); + const deps = buildSequentialDeps([ + { + rows: [{ Period: "2020-01-15T00:00:00", Amount: 10000, Organization: "OOO Alternative Plus" }] + }, + { + rows: [{ Period: "2020-03-10T00:00:00", Amount: 4000, Organization: "OOO Alternative Plus" }] + } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.pilot_scope).toBe("counterparty_bidirectional_value_flow_query_movements_v1"); + expect(result.derived_value_flow).toBeNull(); + expect(result.derived_bidirectional_value_flow).toMatchObject({ + counterparty: null, + period_scope: "2020", + net_amount: 6000, + net_direction: "net_incoming", + incoming_customer_revenue: { + rows_matched: 1, + total_amount: 10000 + }, + outgoing_supplier_payout: { + rows_matched: 1, + total_amount: 4000 + } + }); + expect(result.reason_codes).toContain("pilot_bidirectional_value_flow_recipes_selected"); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(2); + const serializedCalls = deps.executeAddressMcpQuery.mock.calls + .map((call) => JSON.stringify(call[0])) + .join("\n"); + expect(serializedCalls).not.toContain("SVK"); + }); + it("prefers clean counterparty candidates for bidirectional net value-flow probes", async () => { const planner = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 0dab922..dca30d7 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -1280,6 +1280,10 @@ describe("assistant MCP discovery response candidate", () => { ); expect(candidate.reply_text?.split("\n")[0]).toContain("по компании ООО Альтернатива Плюс за период 2020"); + expect(candidate.reply_text).toContain("Основа: проверенные входящие платежи и исходящие платежи по этой компании в 1С."); + expect(candidate.reply_text).toContain("нетто в сторону входящих платежей: 3 865 501,50 руб."); + expect(candidate.reply_text).not.toContain("Коротко:"); + expect(candidate.reply_text).not.toContain("по этому контрагенту"); expect(candidate.reply_text).not.toContain("по контрагенту запрошенному контрагенту"); }); @@ -1918,7 +1922,7 @@ describe("assistant MCP discovery response candidate", () => { ); const firstLine = candidate.reply_text?.split("\n")[0] ?? ""; - expect(firstLine).toContain("Коротко:"); + expect(firstLine).not.toContain("Коротко:"); expect(firstLine).toContain("SVK"); expect(firstLine).toContain("получили 12 500,50 руб."); expect(firstLine).toContain("заплатили 4 000 руб."); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index 094cf3a..742b123 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -57,6 +57,7 @@ describe("assistant MCP discovery response policy", () => { mcpDiscoveryRuntimeEntryPoint: entryPoint({ bridge: { bridge_status: "answer_draft_ready", + hot_runtime_wired: true, user_facing_response_allowed: true, business_fact_answer_allowed: true, requires_user_clarification: false, @@ -79,6 +80,56 @@ describe("assistant MCP discovery response policy", () => { expect(result.reply_text).not.toContain("Коротко:"); }); + it("keeps current organization clarification over a checked-sources value-flow candidate", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Нужно уточнить организацию, чтобы не смешивать компании в одном ответе.\nСейчас в доступном контуре вижу такие организации:\n- ООО Альтернатива Плюс\n- ООО Ромашка", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "partial_coverage", + addressRuntimeMeta: { + truth_mode: "clarification_required", + answer_shape: "clarification_required", + answer_shape_contract: { + answer_shape: "clarification_required", + reply_type: "partial_coverage" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + data_need_graph: { + business_fact_family: "value_flow", + subject_candidates: [], + clarification_gaps: ["organization"], + reason_codes: ["data_need_graph_open_scope_total_needs_organization"] + } + }, + bridge: { + bridge_status: "checked_sources_only", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "checked_sources_only", + headline: "В проверенном срезе 1С входящих денежных поступлений не найдено.", + confirmed_lines: ["В проверенном срезе 1С входящих денежных поступлений не найдено."], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.reply_text).toContain("Нужно уточнить организацию"); + expect(result.reply_text).toContain("ООО Альтернатива Плюс"); + expect(result.reply_text).not.toContain("не найдено"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply"); + }); + it("keeps the current reply when the turn is not an unsupported boundary", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "regular chat", @@ -236,6 +287,7 @@ describe("assistant MCP discovery response policy", () => { }, bridge: { bridge_status: "answer_draft_ready", + hot_runtime_wired: true, user_facing_response_allowed: true, business_fact_answer_allowed: true, requires_user_clarification: false, @@ -341,6 +393,68 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); }); + it("uses a movement clarification candidate over a stale inventory-item clarification", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Need item before I can continue.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "partial_coverage", + addressRuntimeMeta: { + detected_intent: "inventory_purchase_documents_for_item", + selected_recipe: "address_inventory_purchase_documents_for_item_v1", + capability_id: "inventory_inventory_purchase_documents_for_item", + truth_mode: "clarification_required", + answer_shape: "clarification_required", + missing_required_filters: ["item"], + answer_shape_contract: { + answer_shape: "clarification_required", + reply_type: "partial_coverage", + capability_contract_id: "inventory_inventory_purchase_documents_for_item" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["Group SVK"], + unsupported_but_understood_family: "movement_evidence" + }, + data_need_graph: { + business_fact_family: "movement_evidence", + action_family: "list_movements", + clarification_gaps: ["period"] + } + }, + bridge: { + bridge_status: "needs_clarification", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "I can continue by movements for Group SVK, but I need a period.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: ["The period is required before searching movements."], + limitation_lines: [], + next_step_line: "Specify the period and I will search movements for Group SVK." + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("movements for Group SVK"); + expect(result.reply_text).not.toContain("Need item"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_clarification_candidate_priority_over_stale_address_clarification" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply"); + }); + it("overrides exact document-list replies for explicit counterparty received-paid-net questions", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "Контрагент: Группа СВК. Найдено документов: 19.", @@ -1509,4 +1623,95 @@ describe("assistant MCP discovery response policy", () => { "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate" ); }); + + it("lets a grounded bidirectional value-flow candidate override a stale address clarification reply", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: + "Проверка данных за 2020 год по контрагенту «Группа СВК» не удалась — такой точный или аналогичный вариант имени не найден.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "partial_coverage", + addressRuntimeMeta: { + truth_mode: "clarification_required", + answer_shape: "clarification_required", + detected_intent: "counterparty_population_and_roles", + selected_recipe: "address_counterparty_population_roles_v1", + assistant_mcp_discovery_entry_point_v1: entryPoint({ + hot_runtime_wired: true, + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["Группа СВК"], + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" + }, + data_need_graph: { + business_fact_family: "value_flow", + action_family: "net_value_flow", + comparison_need: "incoming_vs_outgoing", + clarification_gaps: [] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + hot_runtime_wired: true, + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + route_candidate: { + selected_chain_id: "value_flow_comparison" + }, + execution_handoff: { + mcp_execution_performed: true, + can_use_guarded_response: true, + must_keep_internal_mechanics_hidden: true + }, + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + mcp_execution_performed: true, + derived_bidirectional_value_flow: { + counterparty: "Группа СВК", + period_scope: "2020", + net_amount_human_ru: "12 093 465 руб.", + net_direction: "net_incoming", + coverage_limited_by_probe_limit: false, + incoming_customer_revenue: { + total_amount_human_ru: "12 093 465 руб." + }, + outgoing_supplier_payout: { + total_amount_human_ru: "0 руб." + } + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Двусторонний денежный поток подтвержден.", + confirmed_lines: [ + "1C bidirectional value-flow rows were checked for counterparty Группа СВК: incoming=found, outgoing=found" + ], + inference_lines: [ + "Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows" + ], + unknown_lines: ["Full bidirectional value-flow outside the checked period is not proven by this MCP discovery pilot"], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("Группа СВК"); + expect(result.reply_text).toContain("получили 12 093 465 руб."); + expect(result.reply_text).toContain("заплатили 0 руб."); + expect(result.reply_text).not.toContain("не найден"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_grounded_value_flow_candidate_priority_over_current_clarification" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_current_clarification_required_reply"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 77fbcdf..70debca 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -88,6 +88,36 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.turn_meaning_ref?.explicit_organization_scope).toBeUndefined(); }); + it("does not treat no-counterparty organization scope as a counterparty entity", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "Хочу быстрый денежный срез по одной организации без привязки к контрагенту. Сколько вообще входящих денег было за 2020 год?", + assistantTurnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "counterparty_value_or_turnover", + explicit_entity_candidates: ["одной организации без привязки"], + explicit_organization_scope: "без привязки к контрагенту", + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_value_or_turnover", + stale_replay_forbidden: true + }, + predecomposeContract: { + entities: {}, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + }, + knownOrganizations: ["ООО Альтернатива Плюс", "ООО Ромашка"] + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.data_need_graph?.business_fact_family).toBe("value_flow"); + expect(result.data_need_graph?.subject_candidates).toEqual([]); + expect(result.data_need_graph?.clarification_gaps).toContain("organization"); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.turn_meaning_ref?.explicit_organization_scope).toBeUndefined(); + expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2020"); + expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_raw_scope"); + }); + it("keeps payout wording as outgoing supplier-payout discovery", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "сколько мы заплатили Группа СВК за 2020 год?", diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index 807c6bf..3e79073 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -1,4 +1,94 @@ [ + { + "generation_id": "gen-ag06031252-8c5319", + "created_at": "2026-06-03T12:52:51+00:00", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 83 mixed planner-brain replay for catalog alignment, pivots, and legacy continuity", + "count": 20, + "domain": "planner_autonomy_consolidation", + "questions": [ + "привет, ты на связи?", + "найди в 1С контрагента СВК", + "сколько получили по нему за 2020 год", + "а теперь сколько заплатили?", + "а какое нетто?", + "а по документам?", + "а по движениям?", + "Хочу быстрый денежный срез по одной организации без привязки к контрагенту. Сколько вообще входящих денег было за 2020 год?", + "По ООО Альтернатива Плюс.", + "Понял, тогда за все время.", + "Хорошо. А что по ООО Альтернатива Плюс больше в 2020 году: входящие или исходящие деньги?", + "А что по ООО Альтернатива Плюс больше уже за 2021 год: входящие или исходящие деньги?", + "И кто больше всего принес денег этой организации в 2020 году?", + "А в 2021 году?", + "а по Альтернативе Плюс сколько лет активности в базе 1С?", + "Как ты оценишь деятельность компании?", + "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?", + "какие справочники 1С есть по контрагентам?", + "давай дальше", + "а чем капибара отличается от утки?" + ], + "generated_by": "codex_agent", + "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json", + "context": { + "llm_provider": null, + "model": null, + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "autogen_personality_id": null, + "autogen_personality_prompt": null, + "source_session_id": null, + "saved_session_file": "assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json", + "saved_case_set_kind": "agent_semantic_scenario", + "agent_run": true, + "agent_focus": "Mixed AGENT replay for Planner Autonomy Consolidation. The pack interleaves selected-counterparty catalog-alignment probes, open-organization money flow, ranking, broad-evaluation continuity, metadata drilldown, and off-domain living-chat safety.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase83_planner_brain_alignment_mix.json", + "scenario_id": "address_truth_harness_phase83_planner_brain_alignment_mix", + "semantic_tags": [ + "alias_grounding", + "all_time_followup", + "broad_business_evaluation", + "broad_eval_bridge_preserved", + "catalog_drilldown", + "catalog_metadata_surface", + "company_activity_lifecycle", + "counterparty_catalog_scope", + "counterparty_net_cash_flow", + "date_carryover", + "document_evidence", + "document_pivot", + "entity_resolution", + "followup_anchor", + "followup_reuse", + "grounded_context_seed", + "grounded_summary", + "human_answer", + "human_dialog", + "incoming_value_flow", + "mcp_discovery_gate_sanity", + "meta_smalltalk", + "movement_evidence", + "net_value_flow", + "neutral_followup", + "off_domain_living_chat", + "open_scope_total", + "organization_clarification", + "organization_scope", + "payout_switch", + "planner_catalog_alignment", + "stale_replay_forbidden", + "value_flow_comparison", + "value_flow_pivot", + "value_flow_ranking", + "year_switch" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "saved_after_validated_replay": true + } + }, { "generation_id": "gen-ag06031054-1bd955", "created_at": "2026-06-03T10:54:28+00:00", diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json new file mode 100644 index 0000000..e6ca9d1 --- /dev/null +++ b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260603125251_gen-ag06031252-8c5319.json @@ -0,0 +1,377 @@ +{ + "saved_at": "2026-06-03T12:52:51+00:00", + "generation_id": "gen-ag06031252-8c5319", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 83 mixed planner-brain replay for catalog alignment, pivots, and legacy continuity", + "agent_run": true, + "questions": [ + "привет, ты на связи?", + "найди в 1С контрагента СВК", + "сколько получили по нему за 2020 год", + "а теперь сколько заплатили?", + "а какое нетто?", + "а по документам?", + "а по движениям?", + "Хочу быстрый денежный срез по одной организации без привязки к контрагенту. Сколько вообще входящих денег было за 2020 год?", + "По ООО Альтернатива Плюс.", + "Понял, тогда за все время.", + "Хорошо. А что по ООО Альтернатива Плюс больше в 2020 году: входящие или исходящие деньги?", + "А что по ООО Альтернатива Плюс больше уже за 2021 год: входящие или исходящие деньги?", + "И кто больше всего принес денег этой организации в 2020 году?", + "А в 2021 году?", + "а по Альтернативе Плюс сколько лет активности в базе 1С?", + "Как ты оценишь деятельность компании?", + "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?", + "какие справочники 1С есть по контрагентам?", + "давай дальше", + "а чем капибара отличается от утки?" + ], + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Mixed AGENT replay for Planner Autonomy Consolidation. The pack interleaves selected-counterparty catalog-alignment probes, open-organization money flow, ranking, broad-evaluation continuity, metadata drilldown, and off-domain living-chat safety.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase83_planner_brain_alignment_mix.json", + "scenario_id": "address_truth_harness_phase83_planner_brain_alignment_mix", + "semantic_tags": [ + "alias_grounding", + "all_time_followup", + "broad_business_evaluation", + "broad_eval_bridge_preserved", + "catalog_drilldown", + "catalog_metadata_surface", + "company_activity_lifecycle", + "counterparty_catalog_scope", + "counterparty_net_cash_flow", + "date_carryover", + "document_evidence", + "document_pivot", + "entity_resolution", + "followup_anchor", + "followup_reuse", + "grounded_context_seed", + "grounded_summary", + "human_answer", + "human_dialog", + "incoming_value_flow", + "mcp_discovery_gate_sanity", + "meta_smalltalk", + "movement_evidence", + "net_value_flow", + "neutral_followup", + "off_domain_living_chat", + "open_scope_total", + "organization_clarification", + "organization_scope", + "payout_switch", + "planner_catalog_alignment", + "stale_replay_forbidden", + "value_flow_comparison", + "value_flow_pivot", + "value_flow_ranking", + "year_switch" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 20, + "steps_passed": 20, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "effective_runtime": { + "manifest_path": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6\\effective_runtime.json", + "runner": "domain_truth_harness.run-live", + "git_sha": "f24cddc492138745aa896f52d1a6ebee8bab08ab", + "backend_url": "http://127.0.0.1:8787", + "mcp_proxy_url": "http://127.0.0.1:6003", + "llm_provider": "local", + "llm_model": "unsloth/qwen3-30b-a3b-instruct-2507", + "temperature": 0.8, + "max_output_tokens": 900, + "prompt_version": "normalizer_v2_0_2", + "prompt_source": "file", + "prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1", + "prompt_registry_status": "pass" + }, + "saved_after_validated_replay": true + } + }, + "source_session_id": null, + "session": { + "session_id": null, + "mode": "agent_semantic_run", + "items": [ + { + "message_id": "agent-user-001", + "role": "user", + "text": "привет, ты на связи?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-002", + "role": "user", + "text": "найди в 1С контрагента СВК", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-003", + "role": "user", + "text": "сколько получили по нему за 2020 год", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-004", + "role": "user", + "text": "а теперь сколько заплатили?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-005", + "role": "user", + "text": "а какое нетто?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-006", + "role": "user", + "text": "а по документам?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-007", + "role": "user", + "text": "а по движениям?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-008", + "role": "user", + "text": "Хочу быстрый денежный срез по одной организации без привязки к контрагенту. Сколько вообще входящих денег было за 2020 год?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-009", + "role": "user", + "text": "По ООО Альтернатива Плюс.", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-010", + "role": "user", + "text": "Понял, тогда за все время.", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-011", + "role": "user", + "text": "Хорошо. А что по ООО Альтернатива Плюс больше в 2020 году: входящие или исходящие деньги?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-012", + "role": "user", + "text": "А что по ООО Альтернатива Плюс больше уже за 2021 год: входящие или исходящие деньги?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-013", + "role": "user", + "text": "И кто больше всего принес денег этой организации в 2020 году?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-014", + "role": "user", + "text": "А в 2021 году?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-015", + "role": "user", + "text": "а по Альтернативе Плюс сколько лет активности в базе 1С?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-016", + "role": "user", + "text": "Как ты оценишь деятельность компании?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-017", + "role": "user", + "text": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-018", + "role": "user", + "text": "какие справочники 1С есть по контрагентам?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-019", + "role": "user", + "text": "давай дальше", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-020", + "role": "user", + "text": "а чем капибара отличается от утки?", + "created_at": "2026-06-03T12:52:51+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + } + ], + "agent_run": true, + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Mixed AGENT replay for Planner Autonomy Consolidation. The pack interleaves selected-counterparty catalog-alignment probes, open-organization money flow, ranking, broad-evaluation continuity, metadata drilldown, and off-domain living-chat safety.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase83_planner_brain_alignment_mix.json", + "scenario_id": "address_truth_harness_phase83_planner_brain_alignment_mix", + "semantic_tags": [ + "alias_grounding", + "all_time_followup", + "broad_business_evaluation", + "broad_eval_bridge_preserved", + "catalog_drilldown", + "catalog_metadata_surface", + "company_activity_lifecycle", + "counterparty_catalog_scope", + "counterparty_net_cash_flow", + "date_carryover", + "document_evidence", + "document_pivot", + "entity_resolution", + "followup_anchor", + "followup_reuse", + "grounded_context_seed", + "grounded_summary", + "human_answer", + "human_dialog", + "incoming_value_flow", + "mcp_discovery_gate_sanity", + "meta_smalltalk", + "movement_evidence", + "net_value_flow", + "neutral_followup", + "off_domain_living_chat", + "open_scope_total", + "organization_clarification", + "organization_scope", + "payout_switch", + "planner_catalog_alignment", + "stale_replay_forbidden", + "value_flow_comparison", + "value_flow_pivot", + "value_flow_ranking", + "year_switch" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 20, + "steps_passed": 20, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "effective_runtime": { + "manifest_path": "artifacts\\domain_runs\\phase83_planner_brain_alignment_mix_20260603_live6\\effective_runtime.json", + "runner": "domain_truth_harness.run-live", + "git_sha": "f24cddc492138745aa896f52d1a6ebee8bab08ab", + "backend_url": "http://127.0.0.1:8787", + "mcp_proxy_url": "http://127.0.0.1:6003", + "llm_provider": "local", + "llm_model": "unsloth/qwen3-30b-a3b-instruct-2507", + "temperature": 0.8, + "max_output_tokens": 900, + "prompt_version": "normalizer_v2_0_2", + "prompt_source": "file", + "prompt_hash": "f36e36a5e491cd24511b380e0c0059cb01aea5e2af738fb9f01671cd291735c1", + "prompt_registry_status": "pass" + }, + "saved_after_validated_replay": true + } + } + } +} diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json new file mode 100644 index 0000000..4701bc0 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260603125251_gen-ag06031252-8c5319.json @@ -0,0 +1,85 @@ +{ + "suite_id": "assistant_saved_session_gen-ag06031252-8c5319", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_suite_v0_1", + "generated_at": "2026-06-03T12:52:51+00:00", + "generation_id": "gen-ag06031252-8c5319", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 83 mixed planner-brain replay for catalog alignment, pivots, and legacy continuity", + "domain": "planner_autonomy_consolidation", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "agent_saved_user_sessions", + "title": "AGENT | Phase 83 mixed planner-brain replay for catalog alignment, pivots, and legacy continuity", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "привет, ты на связи?" + }, + { + "user_message": "найди в 1С контрагента СВК" + }, + { + "user_message": "сколько получили по нему за 2020 год" + }, + { + "user_message": "а теперь сколько заплатили?" + }, + { + "user_message": "а какое нетто?" + }, + { + "user_message": "а по документам?" + }, + { + "user_message": "а по движениям?" + }, + { + "user_message": "Хочу быстрый денежный срез по одной организации без привязки к контрагенту. Сколько вообще входящих денег было за 2020 год?" + }, + { + "user_message": "По ООО Альтернатива Плюс." + }, + { + "user_message": "Понял, тогда за все время." + }, + { + "user_message": "Хорошо. А что по ООО Альтернатива Плюс больше в 2020 году: входящие или исходящие деньги?" + }, + { + "user_message": "А что по ООО Альтернатива Плюс больше уже за 2021 год: входящие или исходящие деньги?" + }, + { + "user_message": "И кто больше всего принес денег этой организации в 2020 году?" + }, + { + "user_message": "А в 2021 году?" + }, + { + "user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?" + }, + { + "user_message": "Как ты оценишь деятельность компании?" + }, + { + "user_message": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?" + }, + { + "user_message": "какие справочники 1С есть по контрагентам?" + }, + { + "user_message": "давай дальше" + }, + { + "user_message": "а чем капибара отличается от утки?" + } + ] + } + ] +}