ARCH: закрепить open-scope continuity для org clarification

This commit is contained in:
2026-04-22 21:58:01 +03:00
parent 94e537210c
commit 8a0a4f0922
19 changed files with 981 additions and 29 deletions
@@ -174,6 +174,15 @@ function explicitDateScope(pilot: AssistantMcpDiscoveryPilotExecutionContract):
return normalized.length > 0 ? normalized : null;
}
function explicitOrganizationScope(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
const value = pilot.evidence.query_plan.turn_meaning_ref?.explicit_organization_scope;
if (typeof value !== "string") {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function documentOrMovementScopeRu(pilot: AssistantMcpDiscoveryPilotExecutionContract): string {
const entity = firstEntityCandidate(pilot);
const period = explicitDateScope(pilot);
@@ -210,6 +219,15 @@ function isBidirectionalValueFlowComparisonClarification(
);
}
function isOpenScopeValueFlowClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
return (
pilot.reason_codes.includes("planner_selected_open_scope_value_flow_total_from_data_need_graph") ||
pilot.reason_codes.includes("planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_open_scope_value_flow_total_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_selected_monthly_open_scope_value_flow_total_from_data_need_graph")
);
}
function isDocumentLaneClarification(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
return (
isDocumentPilot(pilot) ||
@@ -241,6 +259,17 @@ function dryRunMissingAxis(pilot: AssistantMcpDiscoveryPilotExecutionContract, a
function clarificationNeedRu(
pilot: AssistantMcpDiscoveryPilotExecutionContract
): { subject: string; verb: string } {
const organizationScopedOpenTotal =
pilot.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") ||
pilot.dry_run.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") ||
pilot.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph");
if (organizationScopedOpenTotal) {
return {
subject: "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e",
verb: "\u043d\u0443\u0436\u043d\u043e"
};
}
const hasCounterparty = dryRunHasAxis(pilot, "counterparty");
const hasAccount = dryRunHasAxis(pilot, "account");
const needsPeriod = dryRunMissingAxis(pilot, "period");
@@ -261,9 +290,17 @@ function clarificationNextStepLine(
pilot: AssistantMcpDiscoveryPilotExecutionContract,
laneLabel: string
): string {
const organizationScopedOpenTotal =
pilot.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") ||
pilot.dry_run.reason_codes.includes("data_need_graph_open_scope_total_needs_organization") ||
pilot.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph") ||
pilot.dry_run.reason_codes.includes("planner_requires_organization_scope_from_data_need_graph");
const needsPeriod = dryRunMissingAxis(pilot, "period");
const needsOrganization = dryRunMissingAxis(pilot, "organization");
const scopeSuffix = laneScopeSuffix(pilot);
if (organizationScopedOpenTotal && !needsPeriod) {
return `Уточните организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`;
}
if (needsPeriod && needsOrganization) {
return `Уточните период и организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`;
}
@@ -378,6 +415,10 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
const need = clarificationNeedRu(pilot);
return `Могу посчитать ranking по денежному потоку между контрагентами, но для bounded поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) {
const need = clarificationNeedRu(pilot);
return `Могу посчитать общий денежный поток в проверяемом окне, но для bounded поиска в 1С ${need.verb} ${need.subject}.`;
}
if (mode === "needs_clarification") {
return "Нужно уточнить контекст перед поиском в 1С.";
}
@@ -422,6 +463,9 @@ function nextStepFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
if (mode === "needs_clarification" && isRankedValueFlowClarification(pilot)) {
return clarificationNextStepLine(pilot, "ranking-поиску между контрагентами");
}
if (mode === "needs_clarification" && isOpenScopeValueFlowClarification(pilot)) {
return clarificationNextStepLine(pilot, "денежному потоку");
}
if (mode === "needs_clarification") {
return "Уточните контрагента, период или организацию, и я смогу выполнить проверку по 1С.";
}
@@ -646,6 +690,8 @@ function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutio
if (!flow) {
return null;
}
const organizationScope = explicitOrganizationScope(pilot);
const organization = organizationScope ? ` по организации ${organizationScope}` : "";
const counterparty = flow.counterparty ? ` по контрагенту ${flow.counterparty}` : "";
const period = flow.period_scope ? ` за период ${flow.period_scope}` : " в проверенном окне";
const movementLabel =
@@ -132,6 +132,40 @@ function comparisonNeedFor(action: string): string | null {
return null;
}
function hasOpenScopeOneSidedValueTotalHint(rawUtterance: string, action: string): boolean {
if (!rawUtterance) {
return false;
}
if (action === "turnover") {
return /(?:\bсколько\s+(?:мы\s+)?(?:получили|получено|входящих(?:\s+денег)?|поступлений|денег\s+пришло)\b|(?:сумма|объем)\s+(?:входящих|поступлений)|поступлений\s+за\b)/iu.test(
rawUtterance
);
}
if (action === "payout") {
return /(?:\bсколько\s+(?:мы\s+)?(?:заплатили|выплатили|потратили|исходящих(?:\s+денег)?|платежей|списаний)\b|(?:сумма|объем)\s+(?:исходящих|платежей|списаний)|(?:платежей|списаний)\s+за\b)/iu.test(
rawUtterance
);
}
return false;
}
function hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance: string, action: string): boolean {
if (!rawUtterance) {
return false;
}
if (action === "turnover") {
return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u043c\u044b\s+)?(?:\u043f\u043e\u043b\u0443\u0447\u0438\u043b\u0438|\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e|\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439|\u0434\u0435\u043d\u0435\u0433\s+\u043f\u0440\u0438\u0448\u043b\u043e)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439)|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439\s+\u0437\u0430)/u.test(
rawUtterance
);
}
if (action === "payout") {
return /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+(?:\u043c\u044b\s+)?(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u0432\u044b\u043f\u043b\u0430\u0442\u0438\u043b\u0438|\u043f\u043e\u0442\u0440\u0430\u0442\u0438\u043b\u0438|\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u0441\u0443\u043c\u043c\u0430|\u043e\u0431\u044a\u0435\u043c)\s+(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445|\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)|(?:\u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439|\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0439)\s+\u0437\u0430)/u.test(
rawUtterance
);
}
return false;
}
function supportsOrganizationScopedOpenTotal(action: string): boolean {
return action === "turnover" || action === "payout";
}
@@ -142,6 +176,7 @@ function allowsOpenScopeWithoutSubject(input: {
organizationScope: string | null;
comparisonNeed: string | null;
rankingNeed: string | null;
oneSidedOpenScopeTotalHint: boolean;
}): boolean {
if (input.family !== "value_flow") {
return false;
@@ -149,7 +184,9 @@ function allowsOpenScopeWithoutSubject(input: {
if (input.rankingNeed || input.comparisonNeed === "incoming_vs_outgoing") {
return true;
}
return Boolean(input.organizationScope && supportsOrganizationScopedOpenTotal(input.action));
return Boolean(
supportsOrganizationScopedOpenTotal(input.action) && (input.organizationScope || input.oneSidedOpenScopeTotalHint)
);
}
function rankingNeedFromRawUtterance(value: string): string | null {
@@ -303,6 +340,7 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
const aggregationNeed = aggregationNeedFor(aggregationAxis);
const comparisonNeed = comparisonNeedFor(action);
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance);
const oneSidedOpenScopeTotalHint = hasOpenScopeOneSidedValueTotalHintUtf8Safe(rawUtterance, action);
const openScopeWithoutSubject =
subjectCandidates.length === 0 &&
allowsOpenScopeWithoutSubject({
@@ -310,13 +348,24 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
action,
organizationScope: explicitOrganizationScope,
comparisonNeed,
rankingNeed
rankingNeed,
oneSidedOpenScopeTotalHint
});
const clarificationGaps: string[] = [];
if (unsupported === "metadata_lane_choice_clarification" || action === "resolve_next_lane") {
pushUnique(clarificationGaps, "lane_family_choice");
}
if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) {
if (
subjectCandidates.length === 0 &&
businessFactFamily === "value_flow" &&
openScopeWithoutSubject &&
!rankingNeed &&
!comparisonNeed &&
oneSidedOpenScopeTotalHint &&
!explicitOrganizationScope
) {
pushUnique(clarificationGaps, "organization");
} else if (subjectCandidates.length === 0 && businessFactFamily !== "schema_surface" && !openScopeWithoutSubject) {
pushUnique(clarificationGaps, "subject");
}
const timeScopeNeed = timeScopeNeedFor({
@@ -353,6 +402,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
if (openScopeWithoutSubject && !rankingNeed && !comparisonNeed) {
pushReason(reasonCodes, "data_need_graph_open_scope_total_without_subject");
}
if (clarificationGaps.includes("organization")) {
pushReason(reasonCodes, "data_need_graph_open_scope_total_needs_organization");
}
if (clarificationGaps.length > 0) {
pushReason(reasonCodes, "data_need_graph_has_clarification_gaps");
}
@@ -102,6 +102,13 @@ function hasSubjectCandidates(graph: AssistantMcpDiscoveryDataNeedGraphContract
return (graph?.subject_candidates.length ?? 0) > 0;
}
function hasReasonCode(
graph: AssistantMcpDiscoveryDataNeedGraphContract | null | undefined,
reasonCode: string
): boolean {
return (graph?.reason_codes ?? []).includes(reasonCode);
}
function aggregationAxis(meaning: AssistantMcpDiscoveryTurnMeaningRef | null | undefined): string | null {
return toNonEmptyString(meaning?.asked_aggregation_axis)?.toLowerCase() ?? null;
}
@@ -154,6 +161,10 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe {
const graphAggregation = lower(dataNeedGraph?.aggregation_need);
const graphClarificationGaps = (dataNeedGraph?.clarification_gaps ?? []).map((item) => lower(item));
const organizationScope = toNonEmptyString(meaning?.explicit_organization_scope);
const openScopeTotalWithoutSubject =
graphFactFamily === "value_flow" &&
!hasSubjectCandidates(dataNeedGraph) &&
hasReasonCode(dataNeedGraph, "data_need_graph_open_scope_total_without_subject");
const combined = `${domain} ${action} ${unsupported}`.trim();
const axes: string[] = [];
const requestedAggregationAxis = aggregationAxis(meaning);
@@ -205,7 +216,8 @@ function recipeFor(input: AssistantMcpDiscoveryPlannerInput): PlannerRecipe {
: "planner_selected_top_ranked_value_flow_from_data_need_graph"
};
}
if (!hasSubjectCandidates(dataNeedGraph) && organizationScope) {
if (openScopeTotalWithoutSubject) {
pushUnique(axes, "organization");
pushUnique(axes, "aggregate_axis");
pushUnique(axes, "amount");
pushUnique(axes, "coverage_target");
@@ -451,7 +463,30 @@ export function planAssistantMcpDiscovery(
maxProbeCount: budgetOverride.maxProbeCount
});
const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan);
const plannerStatus = statusFrom(plan, review);
const organizationClarificationRequired =
(dataNeedGraph?.clarification_gaps ?? []).includes("organization") &&
!toNonEmptyString(input.turnMeaning?.explicit_organization_scope);
const adjustedReview =
organizationClarificationRequired && recipe.primitives.includes("query_movements")
? {
...review,
review_status: "needs_more_axes" as const,
missing_axes_by_primitive: {
...review.missing_axes_by_primitive,
query_movements: review.missing_axes_by_primitive.query_movements?.length
? review.missing_axes_by_primitive.query_movements
: [["organization"]]
},
reason_codes: review.reason_codes.includes("catalog_requires_organization_scope_from_data_need_graph")
? review.reason_codes
: [...review.reason_codes, "catalog_requires_organization_scope_from_data_need_graph"]
}
: review;
const plannerStatus = organizationClarificationRequired ? "needs_clarification" : statusFrom(plan, adjustedReview);
if (organizationClarificationRequired) {
pushReason(reasonCodes, "planner_requires_organization_scope_from_data_need_graph");
}
if (plannerStatus === "ready_for_execution") {
pushReason(reasonCodes, "planner_ready_for_guarded_mcp_execution");
@@ -467,12 +502,12 @@ export function planAssistantMcpDiscovery(
planner_status: plannerStatus,
semantic_data_need: semanticDataNeed,
data_need_graph: dataNeedGraph,
selected_chain_id: recipe.chainId,
selected_chain_summary: recipe.chainSummary,
proposed_primitives: recipe.primitives,
required_axes: recipe.axes,
discovery_plan: plan,
catalog_review: review,
reason_codes: reasonCodes
};
selected_chain_id: recipe.chainId,
selected_chain_summary: recipe.chainSummary,
proposed_primitives: recipe.primitives,
required_axes: recipe.axes,
discovery_plan: plan,
catalog_review: adjustedReview,
reason_codes: reasonCodes
};
}
@@ -214,6 +214,27 @@ function readDiscoveryTurnMeaning(
return toRecordObject(turnInput?.turn_meaning_ref);
}
function readDiscoveryDataNeedGraph(
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): Record<string, unknown> | null {
const turnInput = toRecordObject(entryPoint?.turn_input);
return toRecordObject(turnInput?.data_need_graph);
}
function isOpenScopeValueFlowWithoutSubject(
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): boolean {
const graph = readDiscoveryDataNeedGraph(entryPoint);
const businessFactFamily = toNonEmptyString(graph?.business_fact_family);
const subjectCandidates = Array.isArray(graph?.subject_candidates) ? graph.subject_candidates : [];
const reasonCodes = Array.isArray(graph?.reason_codes) ? graph.reason_codes : [];
return (
businessFactFamily === "value_flow" &&
subjectCandidates.length === 0 &&
reasonCodes.some((reason) => toNonEmptyString(reason) === "data_need_graph_open_scope_total_without_subject")
);
}
function readTruthAnswerShape(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): Record<string, unknown> | null {
const directShape = toRecordObject(input.addressRuntimeMeta?.answer_shape_contract);
if (directShape) {
@@ -286,6 +307,9 @@ function hasAlignedFactualAddressReply(
if (!hasEffectivelyFactualAddressReply(input)) {
return false;
}
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
return isDetectedIntentAlignedWithTurnMeaning(detectedIntent, readDiscoveryTurnMeaning(entryPoint));
}
@@ -311,6 +335,12 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(
if (!detectedIntent || (!askedDomain && !askedAction && !unsupportedFamily)) {
return false;
}
if (
detectedIntent === "customer_revenue_and_payments" &&
isOpenScopeValueFlowWithoutSubject(entryPoint)
) {
return true;
}
return !isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning);
}
@@ -436,6 +436,20 @@ function hasOrganizationScopeSignalUtf8(text: string): boolean {
);
}
function extractOrganizationScopeFromRawText(value: unknown): string | null {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const match = text.match(
/(?:^|[\s,;:])(?:\u043f\u043e|for|in|within)?\s*((?:\u041e\u041e\u041e|\u0418\u041f|\u0410\u041e|\u041f\u0410\u041e|\u0417\u0410\u041e|LLC|Inc|LTD|Corp)\s+[^\n,.;:!?]+)/u
);
if (!match?.[1]) {
return null;
}
return toNonEmptyString(match[1]);
}
function hasMonthlyAggregationSignal(text: string): boolean {
return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test(
text
@@ -803,18 +817,35 @@ export function buildAssistantMcpDiscoveryTurnInput(
const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const explicitOrganizationScopeSignal = Boolean(
rawOrganizationMentionSignal &&
(rawOrganizationScope ?? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope)
);
const organizationClarificationFollowupApplicable = Boolean(
followupSeed.domain === "counterparty_value" &&
!followupSeed.counterparty &&
rawOrganizationMentionSignal &&
(rawOrganizationScope || followupSeed.organization) &&
!rawLifecycleSignal &&
!rawValueFlowSignal &&
!rawMetadataSignal
);
const rawOpenScopeValueFlowOrganizationSignal = Boolean(
rawValueFlowSignal &&
!rawBidirectionalValueFlowSignal &&
hasOrganizationScopeSignalUtf8(rawText) &&
(predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope)
explicitOrganizationScopeSignal
);
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(
predecomposeEntities.counterparty,
predecomposeEntities.organization
);
const organizationMirrorsPredecomposeCounterparty = Boolean(
(rawBidirectionalValueFlowSignal || hasValueRankingSignal(rawText) || rawOpenScopeValueFlowOrganizationSignal) &&
(rawBidirectionalValueFlowSignal ||
hasValueRankingSignal(rawText) ||
rawOpenScopeValueFlowOrganizationSignal ||
explicitOrganizationScopeSignal) &&
(sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
predecomposeOrganizationMirrorsCounterparty)
);
@@ -826,7 +857,11 @@ export function buildAssistantMcpDiscoveryTurnInput(
followupSeed.domain &&
!rawLifecycleSignal &&
!rawValueFlowSignal &&
(monthlyAggregationSignal || explicitDateScopeLiteralDetected || predecomposeDateScope)
(monthlyAggregationSignal ||
explicitDateScopeLiteralDetected ||
predecomposeDateScope ||
explicitOrganizationScopeSignal ||
organizationClarificationFollowupApplicable)
);
const metadataFollowupSeedApplicable = Boolean(
followupSeed.domain === "metadata" &&
@@ -1114,6 +1149,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
bidirectionalValueFlowSignal ||
hasValueRankingSignal(rawText) ||
rawOpenScopeValueFlowOrganizationSignal ||
explicitOrganizationScopeSignal ||
followupSeed.organization
);
if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) {
@@ -1122,7 +1158,10 @@ export function buildAssistantMcpDiscoveryTurnInput(
}
const explicitOrganizationScope =
valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty
? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization
? rawOrganizationScope ??
predecomposeEntities.organization ??
assistantTurnMeaningOrganizationScope ??
followupSeed.organization
: null;
if (valueFlowOrganizationStaysScope && explicitOrganizationScope) {
for (let index = entityCandidates.length - 1; index >= 0; index -= 1) {
@@ -1258,6 +1297,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
groundedValueFlowFollowupApplicable,
forceDiscoveryOverExplicitIntent:
Boolean(entityResolutionClarificationCandidate) ||
organizationClarificationFollowupApplicable ||
metadataAmbiguityLaneClarificationApplicable ||
metadataGroundedMovementLaneApplicable ||
metadataGroundedDocumentLaneApplicable ||
@@ -1308,6 +1348,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (entityResolutionClarificationCandidate) {
pushReason(reasonCodes, "mcp_discovery_entity_resolution_clarification_candidate_selected");
}
if (organizationClarificationFollowupApplicable) {
pushReason(reasonCodes, "mcp_discovery_organization_clarification_followup_from_followup_context");
}
if (payoutSignal) {
pushReason(reasonCodes, "mcp_discovery_payout_signal_detected");
}