ARCH: замкнуть multi-hop ranking clarification loop

This commit is contained in:
dctouch 2026-04-23 12:20:39 +03:00
parent e4cab85dd9
commit e18b0922ad
14 changed files with 363 additions and 20 deletions

View File

@ -0,0 +1,70 @@
{
"schema_version": "domain_truth_harness_spec_v1",
"scenario_id": "address_truth_harness_phase43_multi_hop_ranking_clarification_loop",
"domain": "address_phase43_multi_hop_ranking_clarification_loop",
"title": "Phase 43 multi-hop ranking clarification loop",
"description": "Targeted AGENT replay for Big Block F where an open-scope ranking question must ask for both organization and period, then keep the same ranking loop after an organization-only clarification, and finally answer after the second clarification provides the period.",
"bindings": {},
"steps": [
{
"step_id": "step_01_ranking_requires_org_and_period",
"title": "Generic ranking question asks for both organization and period",
"question": "Кто больше всего принес денег?",
"allowed_reply_types": ["clarification_required", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)уточн|нужно",
"(?i)организац",
"(?i)период"
],
"forbidden_answer_patterns": [
"(?i)уточните контрагента",
"(?i)не найден контрагент",
"(?i)по какому контрагенту",
"(?i)не найдено контрагента"
],
"criticality": "critical",
"semantic_tags": ["value_flow_ranking", "multi_hop_clarification", "organization_scope", "period_scope", "bounded_autonomy"]
},
{
"step_id": "step_02_org_only_clarification_keeps_same_loop",
"title": "Organization-only clarification preserves the same ranking loop and asks only for the period",
"question": "по ООО Альтернатива Плюс",
"allowed_reply_types": ["clarification_required", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)уточн|нужно",
"(?i)период"
],
"forbidden_answer_patterns": [
"(?i)организац",
"(?i)уточните контрагента",
"(?i)не найден контрагент"
],
"criticality": "critical",
"semantic_tags": ["value_flow_ranking", "multi_hop_clarification", "organization_followup_reuse", "bounded_autonomy"]
},
{
"step_id": "step_03_period_clarification_completes_same_ranking_loop",
"title": "Period clarification completes the same ranking loop and yields a bounded ranking answer",
"question": "за 2020 год",
"allowed_reply_types": ["factual_with_explanation", "partial_coverage"],
"required_answer_patterns_all": [
"(?i)2020",
"(?i)клиент|контрагент|заказчик",
"(?i)руб"
],
"required_answer_patterns_any": [
"(?i)больше всего|топ|самый доходный|наибол",
"(?i)альтернатива",
"(?i)проверенн|найденн"
],
"forbidden_answer_patterns": [
"(?i)уточните организацию",
"(?i)уточните период",
"(?i)уточните контрагента",
"(?i)не найден контрагент"
],
"criticality": "critical",
"semantic_tags": ["value_flow_ranking", "multi_hop_clarification", "period_followup_reuse", "bounded_autonomy"]
}
]
}

View File

@ -193,12 +193,24 @@ function dryRunMissingAxis(pilot, axis) {
}
return pilot.dry_run.execution_steps.some((step) => step.missing_axis_options.some((option) => option.includes(axis)));
}
function queryPlanClarificationGaps(pilot) {
const values = pilot.evidence.query_plan.clarification_gaps;
return Array.isArray(values) ? uniqueStrings(values) : [];
}
function clarificationGapMissing(pilot, axis) {
const gaps = queryPlanClarificationGaps(pilot);
if (gaps.length > 0) {
return gaps.includes(axis);
}
return dryRunMissingAxis(pilot, axis);
}
function clarificationNeedRu(pilot) {
const needsPeriod = clarificationGapMissing(pilot, "period");
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) {
if (organizationScopedOpenTotal && !needsPeriod) {
return {
subject: "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e",
verb: "\u043d\u0443\u0436\u043d\u043e"
@ -206,8 +218,7 @@ function clarificationNeedRu(pilot) {
}
const hasCounterparty = dryRunHasAxis(pilot, "counterparty");
const hasAccount = dryRunHasAxis(pilot, "account");
const needsPeriod = dryRunMissingAxis(pilot, "period");
const needsOrganization = !hasCounterparty && !hasAccount && dryRunMissingAxis(pilot, "organization");
const needsOrganization = !hasCounterparty && !hasAccount && clarificationGapMissing(pilot, "organization");
if (needsPeriod && needsOrganization) {
return { subject: "проверяемый период и организацию", verb: "нужно" };
}
@ -224,8 +235,8 @@ function clarificationNextStepLine(pilot, laneLabel) {
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 needsPeriod = clarificationGapMissing(pilot, "period");
const needsOrganization = clarificationGapMissing(pilot, "organization");
const scopeSuffix = laneScopeSuffix(pilot);
if (organizationScopedOpenTotal && !needsPeriod) {
return `Уточните организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`;

View File

@ -677,6 +677,7 @@ function planAssistantMcpDiscovery(input) {
turnMeaning: input.turnMeaning,
proposedPrimitives: recipe.primitives,
requiredAxes: recipe.axes,
clarificationGaps: dataNeedGraph?.clarification_gaps ?? [],
maxProbeCount: budgetOverride.maxProbeCount
});
const review = (0, assistantMcpCatalogIndex_1.reviewAssistantMcpDiscoveryPlanAgainstCatalog)(plan);

View File

@ -134,6 +134,7 @@ function buildAssistantMcpDiscoveryPlan(input) {
const semanticDataNeed = toNonEmptyString(input.semanticDataNeed);
const turnMeaning = normalizeTurnMeaning(input.turnMeaning);
const requiredAxes = toStringList(input.requiredAxes);
const clarificationGaps = toStringList(input.clarificationGaps);
const proposed = toStringList(input.proposedPrimitives);
const reasonCodes = [];
const allowedPrimitives = [];
@ -194,6 +195,7 @@ function buildAssistantMcpDiscoveryPlan(input) {
allowed_primitives: allowedPrimitives,
rejected_primitives: rejectedPrimitives,
required_axes: requiredAxes,
clarification_gaps: clarificationGaps,
execution_budget: {
max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT),
max_rows_per_probe: clampInteger(input.maxRowsPerProbe, DEFAULT_DISCOVERY_BUDGET.max_rows_per_probe, 1, MAX_ROWS_PER_PROBE)

View File

@ -84,6 +84,7 @@ function entityCandidatesFromPlanner(planner) {
return uniqueStrings(values);
}
function buildLoopState(planner, pilot, bridgeStatus) {
const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? [];
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
@ -94,7 +95,7 @@ function buildLoopState(planner, pilot, bridgeStatus) {
asked_action_family: planner.discovery_plan.turn_meaning_ref?.asked_action_family ?? null,
unsupported_but_understood_family: planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family ?? null,
ranking_need: planner.data_need_graph?.ranking_need ?? planner.discovery_plan.turn_meaning_ref?.seeded_ranking_need ?? null,
pending_axes: flattenAxes(pilot, "missing_axis_options"),
pending_axes: plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"),
provided_axes: flattenAxes(pilot, "provided_axes"),
explicit_entity_candidates: entityCandidatesFromPlanner(planner),
explicit_organization_scope: planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope ?? null,

View File

@ -647,6 +647,15 @@ function collectDateScopeFromRawText(text) {
}
return null;
}
function currentIsoDate() {
return new Date().toISOString().slice(0, 10);
}
function hasRelativeCurrentDateHint(text) {
return /(?:\bсегодня\b|\bна\s+сегодня\b|\bсегодняшн(?:ий|его|ем)\b|\btoday\b|\bas\s+of\s+today\b|\bcurrent\s+date\b)/iu.test(text);
}
function isImplicitCurrentDateScope(value) {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && value === currentIsoDate());
}
function semanticNeedFor(input) {
const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`);
if (input.metadataSignal || /(?:metadata|schema|catalog|inspect_(?:catalog|documents|registers|fields))/iu.test(combined)) {
@ -717,6 +726,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText);
const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(rawText);
const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText);
const rawDateScope = collectDateScopeFromRawText(rawText);
const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null;
const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null;
@ -1023,6 +1033,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
explicitOrganizationScopeSignal ||
organizationClarificationFollowupApplicable ||
followupSeed.organization);
const openScopeValueFlowWithoutResolvedCounterparty = Boolean(valueFlowSignal && !normalizedPredecomposeCounterparty && !followupSeed.counterparty);
if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) {
pushUnique(entityCandidates, predecomposeEntities.organization);
pushUnique(entityCandidates, followupSeed.organization);
@ -1037,14 +1048,33 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
}
}
}
const clarificationLoopStillNeedsPeriod = Boolean(followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period"));
const currentTurnCarriesExplicitPeriod = Boolean(explicitDateScopeLiteralDetected ||
rawDateScope ||
relativeCurrentDateHintDetected ||
(predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope)));
const suppressImplicitCurrentDateScope = Boolean(!currentTurnCarriesExplicitPeriod &&
(clarificationLoopStillNeedsPeriod ||
openScopeValueFlowWithoutResolvedCounterparty ||
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))));
const normalizedPredecomposeDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope) ? null : predecomposeDateScope;
const normalizedAssistantTurnMeaningDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope)
? null
: assistantTurnMeaningDateScope;
const normalizedFollowupDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)
? null
: followupSeed.dateScope;
const explicitDateScope = rawAllTimeScopeSignal
? null
: assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope;
: normalizedAssistantTurnMeaningDateScope ??
normalizedPredecomposeDateScope ??
rawDateScope ??
normalizedFollowupDateScope;
const followupDateScopeApplied = Boolean(!rawAllTimeScopeSignal &&
!assistantTurnMeaningDateScope &&
!predecomposeDateScope &&
!normalizedAssistantTurnMeaningDateScope &&
!normalizedPredecomposeDateScope &&
!rawDateScope &&
followupSeed.dateScope);
normalizedFollowupDateScope);
const clarificationLoopSeedApplied = Boolean(followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopSelectedChainId);
const turnMeaning = {
asked_domain_family: lifecycleSignal

View File

@ -267,15 +267,29 @@ function dryRunMissingAxis(pilot: AssistantMcpDiscoveryPilotExecutionContract, a
);
}
function queryPlanClarificationGaps(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
const values = pilot.evidence.query_plan.clarification_gaps;
return Array.isArray(values) ? uniqueStrings(values) : [];
}
function clarificationGapMissing(pilot: AssistantMcpDiscoveryPilotExecutionContract, axis: string): boolean {
const gaps = queryPlanClarificationGaps(pilot);
if (gaps.length > 0) {
return gaps.includes(axis);
}
return dryRunMissingAxis(pilot, axis);
}
function clarificationNeedRu(
pilot: AssistantMcpDiscoveryPilotExecutionContract
): { subject: string; verb: string } {
const needsPeriod = clarificationGapMissing(pilot, "period");
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) {
if (organizationScopedOpenTotal && !needsPeriod) {
return {
subject: "\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e",
verb: "\u043d\u0443\u0436\u043d\u043e"
@ -283,8 +297,7 @@ function clarificationNeedRu(
}
const hasCounterparty = dryRunHasAxis(pilot, "counterparty");
const hasAccount = dryRunHasAxis(pilot, "account");
const needsPeriod = dryRunMissingAxis(pilot, "period");
const needsOrganization = !hasCounterparty && !hasAccount && dryRunMissingAxis(pilot, "organization");
const needsOrganization = !hasCounterparty && !hasAccount && clarificationGapMissing(pilot, "organization");
if (needsPeriod && needsOrganization) {
return { subject: "проверяемый период и организацию", verb: "нужно" };
}
@ -306,8 +319,8 @@ function clarificationNextStepLine(
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 needsPeriod = clarificationGapMissing(pilot, "period");
const needsOrganization = clarificationGapMissing(pilot, "organization");
const scopeSuffix = laneScopeSuffix(pilot);
if (organizationScopedOpenTotal && !needsPeriod) {
return `Уточните организацию, и я продолжу поиск по ${laneLabel}${scopeSuffix} в 1С.`;

View File

@ -852,6 +852,7 @@ export function planAssistantMcpDiscovery(
turnMeaning: input.turnMeaning,
proposedPrimitives: recipe.primitives,
requiredAxes: recipe.axes,
clarificationGaps: dataNeedGraph?.clarification_gaps ?? [],
maxProbeCount: budgetOverride.maxProbeCount
});
const review = reviewAssistantMcpDiscoveryPlanAgainstCatalog(plan);

View File

@ -48,6 +48,7 @@ export interface AssistantMcpDiscoveryPlanContract {
allowed_primitives: AssistantMcpDiscoveryPrimitive[];
rejected_primitives: string[];
required_axes: string[];
clarification_gaps: string[];
execution_budget: AssistantMcpDiscoveryExecutionBudget;
requires_evidence_gate: true;
answer_may_use_raw_model_claims: false;
@ -59,6 +60,7 @@ export interface BuildAssistantMcpDiscoveryPlanInput {
turnMeaning?: AssistantMcpDiscoveryTurnMeaningRef | null;
proposedPrimitives?: string[] | null;
requiredAxes?: string[] | null;
clarificationGaps?: string[] | null;
maxProbeCount?: number | null;
maxRowsPerProbe?: number | null;
}
@ -237,6 +239,7 @@ export function buildAssistantMcpDiscoveryPlan(
const semanticDataNeed = toNonEmptyString(input.semanticDataNeed);
const turnMeaning = normalizeTurnMeaning(input.turnMeaning);
const requiredAxes = toStringList(input.requiredAxes);
const clarificationGaps = toStringList(input.clarificationGaps);
const proposed = toStringList(input.proposedPrimitives);
const reasonCodes: string[] = [];
const allowedPrimitives: AssistantMcpDiscoveryPrimitive[] = [];
@ -297,6 +300,7 @@ export function buildAssistantMcpDiscoveryPlan(
allowed_primitives: allowedPrimitives,
rejected_primitives: rejectedPrimitives,
required_axes: requiredAxes,
clarification_gaps: clarificationGaps,
execution_budget: {
max_probe_count: clampInteger(input.maxProbeCount, DEFAULT_DISCOVERY_BUDGET.max_probe_count, 1, MAX_PROBE_COUNT),
max_rows_per_probe: clampInteger(

View File

@ -169,6 +169,7 @@ function buildLoopState(
pilot: AssistantMcpDiscoveryPilotExecutionContract,
bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus
): AssistantMcpDiscoveryLoopStateContract {
const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? [];
return {
schema_version: ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
@ -180,7 +181,7 @@ function buildLoopState(
unsupported_but_understood_family:
planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family ?? null,
ranking_need: planner.data_need_graph?.ranking_need ?? planner.discovery_plan.turn_meaning_ref?.seeded_ranking_need ?? null,
pending_axes: flattenAxes(pilot, "missing_axis_options"),
pending_axes: plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"),
provided_axes: flattenAxes(pilot, "provided_axes"),
explicit_entity_candidates: entityCandidatesFromPlanner(planner),
explicit_organization_scope: planner.discovery_plan.turn_meaning_ref?.explicit_organization_scope ?? null,

View File

@ -881,6 +881,20 @@ function collectDateScopeFromRawText(text: string): string | null {
return null;
}
function currentIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
function hasRelativeCurrentDateHint(text: string): boolean {
return /(?:\bсегодня\b|\bна\s+сегодня\b|\bсегодняшн(?:ий|его|ем)\b|\btoday\b|\bas\s+of\s+today\b|\bcurrent\s+date\b)/iu.test(
text
);
}
function isImplicitCurrentDateScope(value: string | null): boolean {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value) && value === currentIsoDate());
}
function semanticNeedFor(input: {
domain: string | null;
action: string | null;
@ -979,6 +993,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText);
const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(rawText);
const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText);
const rawDateScope = collectDateScopeFromRawText(rawText);
const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null;
const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null;
@ -1356,6 +1371,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
organizationClarificationFollowupApplicable ||
followupSeed.organization
);
const openScopeValueFlowWithoutResolvedCounterparty = Boolean(
valueFlowSignal && !normalizedPredecomposeCounterparty && !followupSeed.counterparty
);
if (openScopeValueFlowWithoutCounterparty && !valueFlowOrganizationStaysScope) {
pushUnique(entityCandidates, predecomposeEntities.organization);
pushUnique(entityCandidates, followupSeed.organization);
@ -1371,16 +1389,44 @@ export function buildAssistantMcpDiscoveryTurnInput(
}
}
}
const clarificationLoopStillNeedsPeriod = Boolean(
followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period")
);
const currentTurnCarriesExplicitPeriod = Boolean(
explicitDateScopeLiteralDetected ||
rawDateScope ||
relativeCurrentDateHintDetected ||
(predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope))
);
const suppressImplicitCurrentDateScope = Boolean(
!currentTurnCarriesExplicitPeriod &&
(clarificationLoopStillNeedsPeriod ||
openScopeValueFlowWithoutResolvedCounterparty ||
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal)))
);
const normalizedPredecomposeDateScope =
suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope) ? null : predecomposeDateScope;
const normalizedAssistantTurnMeaningDateScope =
suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope)
? null
: assistantTurnMeaningDateScope;
const normalizedFollowupDateScope =
suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)
? null
: followupSeed.dateScope;
const explicitDateScope =
rawAllTimeScopeSignal
? null
: assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope;
: normalizedAssistantTurnMeaningDateScope ??
normalizedPredecomposeDateScope ??
rawDateScope ??
normalizedFollowupDateScope;
const followupDateScopeApplied = Boolean(
!rawAllTimeScopeSignal &&
!assistantTurnMeaningDateScope &&
!predecomposeDateScope &&
!normalizedAssistantTurnMeaningDateScope &&
!normalizedPredecomposeDateScope &&
!rawDateScope &&
followupSeed.dateScope
normalizedFollowupDateScope
);
const clarificationLoopSeedApplied = Boolean(
followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopSelectedChainId

View File

@ -256,6 +256,46 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.next_step_line).not.toContain("Уточните контрагента");
});
it("asks for both organization and period when an open ranking still misses both axes", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "period_required",
comparison_need: null,
ranking_need: "top_desc",
proof_expectation: "clarification_required",
clarification_gaps: ["organization", "period"],
decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: [
"data_need_graph_built",
"data_need_graph_ranking_top_desc",
"data_need_graph_open_scope_total_needs_organization",
"data_need_graph_has_clarification_gaps"
]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
seeded_ranking_need: "top_desc"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(planner, buildDeps([]));
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
expect(draft.answer_mode).toBe("needs_clarification");
expect(draft.headline).toContain("период");
expect(draft.headline).toContain("организац");
expect(draft.next_step_line).toContain("период");
expect(draft.next_step_line).toContain("организац");
});
it("asks for organization rather than counterparty on open bidirectional comparison when only the period is known", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {

View File

@ -325,6 +325,89 @@ describe("assistant MCP discovery runtime entry point", () => {
expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_comparison");
});
it("keeps the same ranking loop after organization-only clarification when period is still missing", async () => {
const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({
userMessage: "по ООО Альтернатива Плюс",
predecomposeContract: {
entities: { organization: "ООО Альтернатива Плюс" }
},
followupContext: {
previous_discovery_loop_status: "awaiting_clarification",
previous_discovery_loop_selected_chain_id: "value_flow_ranking",
previous_discovery_loop_pending_axes: ["organization", "period"],
previous_discovery_loop_provided_axes: ["aggregate_axis", "amount", "coverage_target"],
previous_discovery_loop_asked_domain_family: "counterparty_value",
previous_discovery_loop_asked_action_family: "turnover",
previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover",
previous_discovery_ranking_need: "top_desc"
},
deps: buildDeps([])
});
expect(result.entry_status).toBe("bridge_executed");
expect(result.turn_input.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_organization_scope: "ООО Альтернатива Плюс",
seeded_ranking_need: "top_desc"
});
expect(result.turn_input.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
expect(result.bridge?.bridge_status).toBe("needs_clarification");
expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_ranking");
expect(result.bridge?.loop_state).toMatchObject({
loop_status: "awaiting_clarification",
selected_chain_id: "value_flow_ranking",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: null,
ranking_need: "top_desc"
});
expect(result.bridge?.loop_state.pending_axes).toContain("period");
expect(result.bridge?.loop_state.pending_axes).not.toContain("organization");
expect(result.bridge?.answer_draft.next_step_line).toContain("период");
expect(result.bridge?.answer_draft.next_step_line).not.toContain("организацию");
});
it("completes the same ranking loop after the second clarification provides the period", async () => {
const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({
userMessage: "за 2020 год",
followupContext: {
previous_discovery_loop_status: "awaiting_clarification",
previous_discovery_loop_selected_chain_id: "value_flow_ranking",
previous_discovery_loop_pending_axes: ["period"],
previous_discovery_loop_provided_axes: ["aggregate_axis", "amount", "coverage_target", "organization"],
previous_discovery_loop_asked_domain_family: "counterparty_value",
previous_discovery_loop_asked_action_family: "turnover",
previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover",
previous_discovery_ranking_need: "top_desc",
previous_filters: {
organization: "ООО Альтернатива Плюс"
}
},
deps: buildDeps([
{ Period: "2020-01-10T00:00:00", Amount: 1200, Counterparty: "СВК-А" },
{ Period: "2020-03-11T00:00:00", Amount: 800, Counterparty: "СВК-Б" },
{ Period: "2020-05-12T00:00:00", Amount: 900, Counterparty: "СВК-А" }
])
});
expect(result.entry_status).toBe("bridge_executed");
expect(result.turn_input.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020",
seeded_ranking_need: "top_desc"
});
expect(result.bridge?.bridge_status).toBe("answer_draft_ready");
expect(result.bridge?.planner.selected_chain_id).toBe("value_flow_ranking");
expect(result.bridge?.pilot.derived_ranked_value_flow?.ranked_values[0]).toMatchObject({
axis_value: "СВК-А",
total_amount: 2100
});
expect(result.bridge?.loop_state.loop_status).toBe("ready_for_next_hop");
expect(result.bridge?.loop_state.pending_axes).toEqual([]);
});
it.skip("keeps mirrored predecompose organization and counterparty out of the subject lane for open comparison (utf8-safe)", async () => {
const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = await runAssistantMcpDiscoveryRuntimeEntryPoint({

View File

@ -1335,6 +1335,7 @@ describe("assistant MCP discovery turn input adapter", () => {
"probe_coverage"
]);
});
it("keeps organization as scope for open bidirectional comparison wording instead of inventing a subject candidate", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",
@ -1638,6 +1639,45 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).toContain("mcp_discovery_resumed_from_saved_loop_state");
});
it("does not keep an implicit today date while a ranking clarification loop still needs period", () => {
const todayIso = new Date().toISOString().slice(0, 10);
const orgName = "ООО Альтернатива Плюс";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "по ООО Альтернатива Плюс",
assistantTurnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_date_scope: todayIso,
explicit_intent_candidate: "customer_revenue_and_payments"
},
predecomposeContract: {
entities: { organization: orgName }
},
followupContext: {
previous_discovery_loop_status: "awaiting_clarification",
previous_discovery_loop_selected_chain_id: "value_flow_ranking",
previous_discovery_loop_pending_axes: ["organization", "period"],
previous_discovery_loop_provided_axes: ["aggregate_axis", "amount", "coverage_target"],
previous_discovery_loop_asked_domain_family: "counterparty_value",
previous_discovery_loop_asked_action_family: "turnover",
previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover",
previous_discovery_ranking_need: "top_desc",
previous_filters: {
period_to: todayIso
}
}
});
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
seeded_ranking_need: "top_desc",
explicit_organization_scope: orgName
});
expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
expect(result.data_need_graph?.clarification_gaps).toEqual(["period"]);
});
it("resolves metadata lane choice from saved loop state even without a previous pilot scope", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "по движениям",