ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов 2.28 - вынос retry-orchestration адресного лейна (контекстный прогон, primary, retry сырого вопроса, fallback limited) в отдельный адаптер.

This commit is contained in:
2026-04-10 20:21:53 +03:00
parent 9b1d1bff91
commit 3531f7ddfe
9 changed files with 814 additions and 235 deletions
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
import {
runAssistantAddressLaneRuntime,
type AssistantAddressFollowupCarryoverLike,
type AssistantAddressLaneLike
} from "../src/services/assistantAddressLaneRuntimeAdapter";
function limitedLane(category: string): AssistantAddressLaneLike {
return {
handled: true,
debug: {
limited_reason_category: category
}
};
}
function factualLane(): AssistantAddressLaneLike {
return {
handled: true,
debug: {}
};
}
function unhandledLane(): AssistantAddressLaneLike {
return {
handled: false,
debug: {}
};
}
describe("assistant address lane runtime adapter", () => {
it("returns contextual lane immediately when preferred contextual attempt is factual", async () => {
const carryover: AssistantAddressFollowupCarryoverLike = { followupContext: { scope: "ctx" } };
const runAddressLaneAttempt = vi.fn(async () => factualLane());
const result = await runAssistantAddressLaneRuntime({
userMessage: "сырой вопрос",
addressInputMessage: "нормализованный вопрос",
carryover,
shouldPreferContextualLane: true,
canRetryWithRawUserMessage: true,
runAddressLaneAttempt,
isRetryableAddressLimitedResult: (lane) => Boolean(lane?.debug?.limited_reason_category)
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("нормализованный вопрос");
expect(result.selection?.carryMeta).toBe(carryover);
expect(result.retryAudit.attempted).toBe(false);
expect(runAddressLaneAttempt).toHaveBeenCalledTimes(1);
expect(runAddressLaneAttempt).toHaveBeenCalledWith("нормализованный вопрос", carryover);
});
it("retries with raw message after limited result and returns factual retry", async () => {
const carryover: AssistantAddressFollowupCarryoverLike = { followupContext: { scope: "ctx" } };
const runAddressLaneAttempt = vi
.fn()
.mockResolvedValueOnce(limitedLane("empty_match")) // primary
.mockResolvedValueOnce(limitedLane("empty_match")) // contextual
.mockResolvedValueOnce(factualLane()); // raw contextual retry
const result = await runAssistantAddressLaneRuntime({
userMessage: "сырой вопрос",
addressInputMessage: "нормализованный вопрос",
carryover,
shouldPreferContextualLane: false,
canRetryWithRawUserMessage: true,
runAddressLaneAttempt,
isRetryableAddressLimitedResult: (lane) => Boolean(lane?.debug?.limited_reason_category)
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("сырой вопрос");
expect(result.selection?.carryMeta).toBe(carryover);
expect(result.retryAudit.attempted).toBe(true);
expect(result.retryAudit.reason).toBe("limited_result_retry_with_raw_message");
expect(result.retryAudit.initial_limited_category).toBe("empty_match");
expect(result.retryAudit.retry_used_followup_context).toBe(true);
expect(result.retryAudit.retry_result_category).toBe(null);
expect(runAddressLaneAttempt).toHaveBeenCalledTimes(3);
});
it("returns pending limited result when retry is disabled", async () => {
const runAddressLaneAttempt = vi
.fn()
.mockResolvedValueOnce(limitedLane("missing_anchor")) // primary
.mockResolvedValueOnce(unhandledLane()); // contextual fallback
const result = await runAssistantAddressLaneRuntime({
userMessage: "сырой вопрос",
addressInputMessage: "нормализованный вопрос",
carryover: { followupContext: { scope: "ctx" } },
shouldPreferContextualLane: false,
canRetryWithRawUserMessage: false,
runAddressLaneAttempt,
isRetryableAddressLimitedResult: (lane) => Boolean(lane?.debug?.limited_reason_category)
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("нормализованный вопрос");
expect(result.selection?.addressLane.debug?.limited_reason_category).toBe("missing_anchor");
expect(result.retryAudit.attempted).toBe(false);
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import { buildAssistantAddressOrchestrationRuntime } from "../src/services/assistantAddressOrchestrationRuntimeAdapter";
function buildInput(overrides: Record<string, unknown> = {}) {
const runAddressLlmPreDecompose = vi.fn(async () => ({
attempted: true,
applied: true,
effectiveMessage: "канон",
reason: "normalized_fragment_applied"
}));
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
followupContext: { id: "ctx" }
}));
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
runAddressLane: true,
livingMode: "deep_analysis",
livingReason: "address_mode_classifier_detected",
toolGateDecision: "run_address_lane",
toolGateReason: "address_mode_classifier_detected",
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
}));
const buildAddressDialogContinuationContractV2 = vi.fn(() => ({
schema_version: "address_dialog_continuation_contract_v2"
}));
return {
userMessage: "сырой вопрос",
sessionItems: [],
llmProvider: "openai",
useMock: false,
featureAddressLlmPredecomposeV1: true,
runAddressLlmPreDecompose,
buildAddressLlmPredecomposeContractV1: () => ({ schema_version: "address_llm_predecompose_contract_v1" }),
sanitizeAddressMessageForFallback: () => "sanitized",
toNonEmptyString: (value: unknown) => {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
},
resolveAddressFollowupCarryoverContext,
resolveAssistantOrchestrationDecision,
buildAddressDialogContinuationContractV2,
__spies: {
runAddressLlmPreDecompose,
resolveAddressFollowupCarryoverContext,
resolveAssistantOrchestrationDecision,
buildAddressDialogContinuationContractV2
},
...overrides
} as any;
}
describe("assistant address orchestration runtime adapter", () => {
it("uses llm predecompose payload when feature is enabled", async () => {
const input = buildInput();
const output = await buildAssistantAddressOrchestrationRuntime(input);
expect(output.addressPreDecompose.reason).toBe("normalized_fragment_applied");
expect(output.addressInputMessage).toBe("канон");
expect(output.orchestrationDecision.runAddressLane).toBe(true);
expect(output.livingModeDecision.mode).toBe("deep_analysis");
expect(output.addressRuntimeMeta.toolGateDecision).toBe("run_address_lane");
expect(output.addressRuntimeMeta.dialogContinuationContract).toEqual({
schema_version: "address_dialog_continuation_contract_v2"
});
expect(input.__spies.runAddressLlmPreDecompose).toHaveBeenCalledTimes(1);
expect(input.__spies.resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(1);
expect(input.__spies.resolveAssistantOrchestrationDecision).toHaveBeenCalledTimes(1);
});
it("builds deterministic fallback predecompose payload when feature is disabled", async () => {
const input = buildInput({
featureAddressLlmPredecomposeV1: false,
llmProvider: "local",
runAddressLlmPreDecompose: vi.fn(async () => {
throw new Error("must not be called");
}),
resolveAssistantOrchestrationDecision: vi.fn(() => ({
runAddressLane: false,
livingMode: "chat",
livingReason: "predecompose_unsupported_mode",
toolGateDecision: "skip_address_lane",
toolGateReason: "llm_predecompose_unsupported_mode",
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
}))
});
const output = await buildAssistantAddressOrchestrationRuntime(input);
expect(output.addressPreDecompose.attempted).toBe(false);
expect(output.addressPreDecompose.applied).toBe(false);
expect(output.addressPreDecompose.provider).toBe("local");
expect(output.addressPreDecompose.reason).toBe("disabled_by_feature_flag");
expect(output.addressPreDecompose.sanitizedUserMessage).toBe("sanitized");
expect(output.addressInputMessage).toBe("сырой вопрос");
expect(output.livingModeDecision.mode).toBe("chat");
expect(output.addressRuntimeMeta.toolGateDecision).toBe("skip_address_lane");
});
});