Закрепить phase83 planner-brain replay ассистента

This commit is contained in:
2026-06-03 15:56:35 +03:00
parent f24cddc492
commit 5ed25bad81
22 changed files with 1439 additions and 28 deletions
@@ -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)) {
@@ -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),
@@ -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") ||
@@ -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("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
@@ -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 &&
@@ -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, " ")
@@ -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
)} не найдено.`;
}
@@ -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),
@@ -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") ||
@@ -736,13 +736,19 @@ function businessOverviewInventoryLine(overview: Record<string, unknown>): 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("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
@@ -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 &&
@@ -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, " ")
@@ -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: {
@@ -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",
@@ -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: {
@@ -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 руб.");
@@ -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");
});
});
@@ -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 год?",