diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index 6a748ea..b6546eb 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -946,7 +946,57 @@ Validation: - `assistantAddressFollowupContext.test.ts` - `assistantWave10SettlementCorrectiveRegression.test.ts` -Status: **In progress (Phase 2.1 + 2.2 + 2.3 + 2.4 + 2.5 + 2.6 + 2.7 + 2.8 + 2.9 + 2.10 + 2.11 + 2.12 + 2.13 + 2.14 + 2.15 + 2.16 + 2.17 + 2.18 + 2.19 + 2.20 + 2.21 + 2.22 + 2.23 + 2.24 + 2.25 + 2.26 + 2.27 completed)** +Implemented in current pass (Phase 2.28): +1. Extracted address-lane retry orchestration branch from `assistantService` into dedicated runtime adapter: + - `assistantAddressLaneRuntimeAdapter.ts` + - introduced: + - `runAssistantAddressLaneRuntime(...)` +2. Centralized address retry/runtime branch sequence (behavior-preserving): + - contextual-first execution when followup context is preferred; + - primary execution without followup context; + - optional contextual fallback when context exists but is not preferred; + - retry with raw user message for retryable limited results; + - deterministic fallback to pending limited result when retry does not improve outcome. +3. Rewired `assistantService` address lane execution path to consume retry adapter output and preserve existing address finalization contract. +4. Added focused unit tests: + - `assistantAddressLaneRuntimeAdapter.test.ts` + +Validation: +1. `npm run build` passed. +2. Targeted living/address followup pack passed: + - `assistantAddressLaneRuntimeAdapter.test.ts` + - `assistantAddressFollowupContext.test.ts` + - `assistantLivingChatMode.test.ts` + - `assistantLivingRouter.test.ts` + - `assistantWave10SettlementCorrectiveRegression.test.ts` + +Implemented in current pass (Phase 2.29): +1. Extracted address orchestration bootstrap block from `assistantService` into dedicated runtime adapter: + - `assistantAddressOrchestrationRuntimeAdapter.ts` + - introduced: + - `buildAssistantAddressOrchestrationRuntime(...)` +2. Centralized address orchestration bootstrap sequence (behavior-preserving): + - LLM predecompose stage or deterministic fallback contract when feature is disabled; + - effective address input message resolution; + - followup carryover context resolution; + - orchestration/tool-gate decision resolution; + - dialog continuation contract projection into runtime meta; + - living mode decision projection for chat fallback. +3. Rewired `assistantService` address pre-lane bootstrap path to consume orchestration runtime adapter output. +4. Added focused unit tests: + - `assistantAddressOrchestrationRuntimeAdapter.test.ts` + +Validation: +1. `npm run build` passed. +2. Targeted living/address followup pack passed: + - `assistantAddressOrchestrationRuntimeAdapter.test.ts` + - `assistantAddressLaneRuntimeAdapter.test.ts` + - `assistantAddressFollowupContext.test.ts` + - `assistantLivingChatMode.test.ts` + - `assistantLivingRouter.test.ts` + - `assistantWave10SettlementCorrectiveRegression.test.ts` + +Status: **In progress (Phase 2.1 + 2.2 + 2.3 + 2.4 + 2.5 + 2.6 + 2.7 + 2.8 + 2.9 + 2.10 + 2.11 + 2.12 + 2.13 + 2.14 + 2.15 + 2.16 + 2.17 + 2.18 + 2.19 + 2.20 + 2.21 + 2.22 + 2.23 + 2.24 + 2.25 + 2.26 + 2.27 + 2.28 + 2.29 completed)** ## Stage 3 (P2): Hybrid Semantic Layer (LLM + Deterministic Guards) diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneRuntimeAdapter.js new file mode 100644 index 0000000..90f5598 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneRuntimeAdapter.js @@ -0,0 +1,115 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runAssistantAddressLaneRuntime = runAssistantAddressLaneRuntime; +function limitedCategory(addressLane) { + return typeof addressLane?.debug?.limited_reason_category === "string" + ? addressLane.debug.limited_reason_category + : null; +} +async function runAssistantAddressLaneRuntime(input) { + const retryAudit = { + attempted: false, + reason: null, + initial_limited_category: null, + retry_message: null, + retry_used_followup_context: false, + retry_result_category: null + }; + let pendingLimited = null; + const evaluateAddressLane = (addressLane, messageUsed, carryMeta) => { + if (!addressLane?.handled) { + return { action: "continue" }; + } + if (!input.isRetryableAddressLimitedResult(addressLane)) { + return { + action: "return", + selection: { + addressLane, + messageUsed, + carryMeta + } + }; + } + if (!pendingLimited) { + pendingLimited = { + addressLane, + messageUsed, + carryMeta + }; + } + return { action: "continue" }; + }; + if (input.shouldPreferContextualLane) { + const contextualAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, input.carryover); + const decision = evaluateAddressLane(contextualAddressLane, input.addressInputMessage, input.carryover); + if (decision.action === "return") { + return { + handled: true, + selection: decision.selection, + retryAudit + }; + } + } + const primaryAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, null); + const primaryDecision = evaluateAddressLane(primaryAddressLane, input.addressInputMessage, null); + if (primaryDecision.action === "return") { + return { + handled: true, + selection: primaryDecision.selection, + retryAudit + }; + } + if (!input.shouldPreferContextualLane && input.carryover?.followupContext) { + const contextualAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, input.carryover); + const contextualDecision = evaluateAddressLane(contextualAddressLane, input.addressInputMessage, input.carryover); + if (contextualDecision.action === "return") { + return { + handled: true, + selection: contextualDecision.selection, + retryAudit + }; + } + } + const pendingLimitedSelection = pendingLimited; + if (pendingLimitedSelection && input.canRetryWithRawUserMessage) { + retryAudit.attempted = true; + retryAudit.reason = "limited_result_retry_with_raw_message"; + retryAudit.initial_limited_category = limitedCategory(pendingLimitedSelection?.addressLane ?? null); + retryAudit.retry_message = input.userMessage; + if (input.carryover?.followupContext) { + retryAudit.retry_used_followup_context = true; + const rawContextualLane = await input.runAddressLaneAttempt(input.userMessage, input.carryover); + const rawContextualDecision = evaluateAddressLane(rawContextualLane, input.userMessage, input.carryover); + if (rawContextualDecision.action === "return") { + retryAudit.retry_result_category = limitedCategory(rawContextualDecision.selection.addressLane); + return { + handled: true, + selection: rawContextualDecision.selection, + retryAudit + }; + } + } + const rawPrimaryLane = await input.runAddressLaneAttempt(input.userMessage, null); + retryAudit.retry_result_category = limitedCategory(rawPrimaryLane); + const rawPrimaryDecision = evaluateAddressLane(rawPrimaryLane, input.userMessage, null); + if (rawPrimaryDecision.action === "return") { + return { + handled: true, + selection: rawPrimaryDecision.selection, + retryAudit + }; + } + } + if (pendingLimited) { + return { + handled: true, + selection: pendingLimited, + retryAudit + }; + } + return { + handled: false, + selection: null, + retryAudit + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js new file mode 100644 index 0000000..0468bec --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js @@ -0,0 +1,58 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildAssistantAddressOrchestrationRuntime = buildAssistantAddressOrchestrationRuntime; +function fallbackAddressPreDecompose(userMessage, llmProvider, buildAddressLlmPredecomposeContractV1, sanitizeAddressMessageForFallback) { + const provider = llmProvider === "local" ? "local" : llmProvider === "openai" ? "openai" : null; + return { + attempted: false, + applied: false, + provider, + traceId: null, + effectiveMessage: userMessage, + reason: "disabled_by_feature_flag", + llmCanonicalCandidateDetected: false, + predecomposeContract: buildAddressLlmPredecomposeContractV1({ + sourceMessage: userMessage, + canonicalMessage: userMessage + }), + fallbackRuleHit: null, + sanitizedUserMessage: sanitizeAddressMessageForFallback(userMessage), + toolGateDecision: null, + toolGateReason: null + }; +} +async function buildAssistantAddressOrchestrationRuntime(input) { + const addressPreDecompose = input.featureAddressLlmPredecomposeV1 + ? await input.runAddressLlmPreDecompose() + : fallbackAddressPreDecompose(input.userMessage, input.llmProvider, input.buildAddressLlmPredecomposeContractV1, input.sanitizeAddressMessageForFallback); + const addressInputMessage = input.toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? input.userMessage; + const carryover = input.resolveAddressFollowupCarryoverContext(input.userMessage, input.sessionItems, addressInputMessage, addressPreDecompose); + const followupContext = carryover?.followupContext ?? null; + const orchestrationDecision = input.resolveAssistantOrchestrationDecision({ + rawUserMessage: input.userMessage, + effectiveAddressUserMessage: addressInputMessage, + followupContext, + llmPreDecomposeMeta: addressPreDecompose, + useMock: input.useMock + }); + const dialogContinuationContract = input.buildAddressDialogContinuationContractV2(input.userMessage, addressInputMessage, carryover, addressPreDecompose); + const addressRuntimeMeta = { + ...addressPreDecompose, + toolGateDecision: orchestrationDecision.toolGateDecision ?? null, + toolGateReason: orchestrationDecision.toolGateReason ?? null, + dialogContinuationContract, + orchestrationContract: orchestrationDecision.orchestrationContract ?? null + }; + const livingModeDecision = { + mode: orchestrationDecision.livingMode, + reason: orchestrationDecision.livingReason + }; + return { + addressPreDecompose, + addressInputMessage, + carryover, + orchestrationDecision, + addressRuntimeMeta, + livingModeDecision + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 0749f3b..f5b3a0d 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -75,6 +75,8 @@ const assistantDeepTurnGroundingRuntimeAdapter_1 = __importStar(require("./assis const assistantDeepTurnPackagingRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnPackagingRuntimeAdapter")); const assistantDeepTurnPlanRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnPlanRuntimeAdapter")); const assistantDeepTurnRetrievalRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnRetrievalRuntimeAdapter")); +const assistantAddressLaneRuntimeAdapter_1 = __importStar(require("./assistantAddressLaneRuntimeAdapter")); +const assistantAddressOrchestrationRuntimeAdapter_1 = __importStar(require("./assistantAddressOrchestrationRuntimeAdapter")); const assistantLivingChatTurnFinalizeRuntimeAdapter_1 = __importStar(require("./assistantLivingChatTurnFinalizeRuntimeAdapter")); const assistantLivingChatRuntimeAdapter_1 = __importStar(require("./assistantLivingChatRuntimeAdapter")); const assistantQueryPlanning_1 = __importStar(require("./assistantQueryPlanning")); @@ -4560,47 +4562,27 @@ class AssistantService { }; let addressRuntimeMetaForDeep = null; if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) { - const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 - ? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage) - : { - attempted: false, - applied: false, - provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null, - traceId: null, - effectiveMessage: userMessage, - reason: "disabled_by_feature_flag", - llmCanonicalCandidateDetected: false, - predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ - sourceMessage: userMessage, - canonicalMessage: userMessage - }), - fallbackRuleHit: null, - sanitizedUserMessage: sanitizeAddressMessageForFallback(userMessage), - toolGateDecision: null, - toolGateReason: null - }; - const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage; - const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage, addressPreDecompose); - const orchestrationDecision = resolveAssistantOrchestrationDecision({ - rawUserMessage: userMessage, - effectiveAddressUserMessage: addressInputMessage, - followupContext: carryover?.followupContext ?? null, - llmPreDecomposeMeta: addressPreDecompose, - useMock: Boolean(payload.useMock) + const addressOrchestrationRuntime = await (0, assistantAddressOrchestrationRuntimeAdapter_1.buildAssistantAddressOrchestrationRuntime)({ + userMessage, + sessionItems: session.items, + llmProvider: payload?.llmProvider, + useMock: Boolean(payload.useMock), + featureAddressLlmPredecomposeV1: config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1, + runAddressLlmPreDecompose: async () => runAddressLlmPreDecompose(this.normalizerService, payload, userMessage), + buildAddressLlmPredecomposeContractV1: predecomposeContract_1.buildAddressLlmPredecomposeContractV1, + sanitizeAddressMessageForFallback, + toNonEmptyString, + resolveAddressFollowupCarryoverContext, + resolveAssistantOrchestrationDecision, + buildAddressDialogContinuationContractV2 }); - const dialogContinuationContract = buildAddressDialogContinuationContractV2(userMessage, addressInputMessage, carryover, addressPreDecompose); - const addressRuntimeMeta = { - ...addressPreDecompose, - toolGateDecision: orchestrationDecision.toolGateDecision, - toolGateReason: orchestrationDecision.toolGateReason, - dialogContinuationContract, - orchestrationContract: orchestrationDecision.orchestrationContract - }; + const addressPreDecompose = addressOrchestrationRuntime.addressPreDecompose; + const addressInputMessage = addressOrchestrationRuntime.addressInputMessage; + const carryover = addressOrchestrationRuntime.carryover; + const orchestrationDecision = addressOrchestrationRuntime.orchestrationDecision; + const addressRuntimeMeta = addressOrchestrationRuntime.addressRuntimeMeta; addressRuntimeMetaForDeep = addressRuntimeMeta; - const livingModeDecision = { - mode: orchestrationDecision.livingMode, - reason: orchestrationDecision.livingReason - }; + const livingModeDecision = addressOrchestrationRuntime.livingModeDecision; if (!orchestrationDecision.runAddressLane) { (0, log_1.logJson)({ timestamp: new Date().toISOString(), @@ -4637,42 +4619,6 @@ class AssistantService { const analysisDateHint = runtimeAnalysisContext.as_of_date ?? toNonEmptyString(payload?.context?.period_hint); const canRetryWithRawUserMessage = compactWhitespace(String(addressInputMessage ?? "").toLowerCase()) !== compactWhitespace(String(userMessage ?? "").toLowerCase()); - const retryAudit = { - attempted: false, - reason: null, - initial_limited_category: null, - retry_message: null, - retry_used_followup_context: false, - retry_result_category: null - }; - const withRetryMeta = () => ({ - ...addressRuntimeMeta, - addressRetryAudit: { ...retryAudit } - }); - let pendingLimited = null; - const evaluateAddressLane = (addressLane, messageUsed, carryMeta) => { - if (!addressLane?.handled) { - return null; - } - if (!isRetryableAddressLimitedResult(addressLane)) { - return { - action: "return", - addressLane, - messageUsed, - carryMeta - }; - } - if (!pendingLimited) { - pendingLimited = { - addressLane, - messageUsed, - carryMeta - }; - } - return { - action: "continue" - }; - }; const runAddressLaneAttempt = async (messageUsed, carryMeta) => { const scopedFollowupContext = mergeFollowupContextWithOrganizationScope(carryMeta?.followupContext ?? null, sessionOrganizationScope.activeOrganization); if (scopedFollowupContext) { @@ -4685,48 +4631,20 @@ class AssistantService { analysisDateHint }); }; - if (shouldPreferContextualLane) { - const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover); - const decision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover); - if (decision?.action === "return") { - return finalizeAddressLaneResponse(decision.addressLane, decision.messageUsed, decision.carryMeta, withRetryMeta()); - } - } - const primaryAddressLane = await runAddressLaneAttempt(addressInputMessage, null); - const primaryDecision = evaluateAddressLane(primaryAddressLane, addressInputMessage, null); - if (primaryDecision?.action === "return") { - return finalizeAddressLaneResponse(primaryDecision.addressLane, primaryDecision.messageUsed, primaryDecision.carryMeta, withRetryMeta()); - } - if (!shouldPreferContextualLane && carryover?.followupContext) { - const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover); - const contextualDecision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover); - if (contextualDecision?.action === "return") { - return finalizeAddressLaneResponse(contextualDecision.addressLane, contextualDecision.messageUsed, contextualDecision.carryMeta, withRetryMeta()); - } - } - if (pendingLimited && canRetryWithRawUserMessage) { - retryAudit.attempted = true; - retryAudit.reason = "limited_result_retry_with_raw_message"; - retryAudit.initial_limited_category = pendingLimited.addressLane?.debug?.limited_reason_category ?? null; - retryAudit.retry_message = userMessage; - if (carryover?.followupContext) { - retryAudit.retry_used_followup_context = true; - const rawContextualLane = await runAddressLaneAttempt(userMessage, carryover); - const rawContextualDecision = evaluateAddressLane(rawContextualLane, userMessage, carryover); - if (rawContextualDecision?.action === "return") { - retryAudit.retry_result_category = rawContextualDecision.addressLane?.debug?.limited_reason_category ?? null; - return finalizeAddressLaneResponse(rawContextualDecision.addressLane, rawContextualDecision.messageUsed, rawContextualDecision.carryMeta, withRetryMeta()); - } - } - const rawPrimaryLane = await runAddressLaneAttempt(userMessage, null); - retryAudit.retry_result_category = rawPrimaryLane?.debug?.limited_reason_category ?? null; - const rawPrimaryDecision = evaluateAddressLane(rawPrimaryLane, userMessage, null); - if (rawPrimaryDecision?.action === "return") { - return finalizeAddressLaneResponse(rawPrimaryDecision.addressLane, rawPrimaryDecision.messageUsed, rawPrimaryDecision.carryMeta, withRetryMeta()); - } - } - if (pendingLimited) { - return finalizeAddressLaneResponse(pendingLimited.addressLane, pendingLimited.messageUsed, pendingLimited.carryMeta, withRetryMeta()); + const addressLaneRuntime = await (0, assistantAddressLaneRuntimeAdapter_1.runAssistantAddressLaneRuntime)({ + userMessage, + addressInputMessage, + carryover, + shouldPreferContextualLane, + canRetryWithRawUserMessage, + runAddressLaneAttempt, + isRetryableAddressLimitedResult + }); + if (addressLaneRuntime.handled && addressLaneRuntime.selection) { + return finalizeAddressLaneResponse(addressLaneRuntime.selection.addressLane, addressLaneRuntime.selection.messageUsed, addressLaneRuntime.selection.carryMeta, { + ...addressRuntimeMeta, + addressRetryAudit: { ...addressLaneRuntime.retryAudit } + }); } } } diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneRuntimeAdapter.ts new file mode 100644 index 0000000..0dd6684 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantAddressLaneRuntimeAdapter.ts @@ -0,0 +1,179 @@ +export interface AssistantAddressFollowupCarryoverLike { + followupContext?: unknown | null; + [key: string]: unknown; +} + +export interface AssistantAddressLaneLike { + handled?: boolean; + debug?: { + limited_reason_category?: string | null; + [key: string]: unknown; + } | null; + [key: string]: unknown; +} + +export interface AssistantAddressLaneSelection { + addressLane: AssistantAddressLaneLike; + messageUsed: string; + carryMeta: AssistantAddressFollowupCarryoverLike | null; +} + +export interface AssistantAddressLaneRetryAudit { + attempted: boolean; + reason: string | null; + initial_limited_category: string | null; + retry_message: string | null; + retry_used_followup_context: boolean; + retry_result_category: string | null; +} + +export interface RunAssistantAddressLaneRuntimeInput { + userMessage: string; + addressInputMessage: string; + carryover: AssistantAddressFollowupCarryoverLike | null; + shouldPreferContextualLane: boolean; + canRetryWithRawUserMessage: boolean; + runAddressLaneAttempt: ( + messageUsed: string, + carryMeta: AssistantAddressFollowupCarryoverLike | null + ) => Promise; + isRetryableAddressLimitedResult: (addressLane: AssistantAddressLaneLike | null | undefined) => boolean; +} + +export interface RunAssistantAddressLaneRuntimeOutput { + handled: boolean; + selection: AssistantAddressLaneSelection | null; + retryAudit: AssistantAddressLaneRetryAudit; +} + +function limitedCategory(addressLane: AssistantAddressLaneLike | null | undefined): string | null { + return typeof addressLane?.debug?.limited_reason_category === "string" + ? addressLane.debug.limited_reason_category + : null; +} + +export async function runAssistantAddressLaneRuntime( + input: RunAssistantAddressLaneRuntimeInput +): Promise { + const retryAudit: AssistantAddressLaneRetryAudit = { + attempted: false, + reason: null, + initial_limited_category: null, + retry_message: null, + retry_used_followup_context: false, + retry_result_category: null + }; + + let pendingLimited: AssistantAddressLaneSelection | null = null; + + const evaluateAddressLane = ( + addressLane: AssistantAddressLaneLike | null, + messageUsed: string, + carryMeta: AssistantAddressFollowupCarryoverLike | null + ): { action: "return"; selection: AssistantAddressLaneSelection } | { action: "continue" } => { + if (!addressLane?.handled) { + return { action: "continue" }; + } + if (!input.isRetryableAddressLimitedResult(addressLane)) { + return { + action: "return", + selection: { + addressLane, + messageUsed, + carryMeta + } + }; + } + if (!pendingLimited) { + pendingLimited = { + addressLane, + messageUsed, + carryMeta + }; + } + return { action: "continue" }; + }; + + if (input.shouldPreferContextualLane) { + const contextualAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, input.carryover); + const decision = evaluateAddressLane(contextualAddressLane, input.addressInputMessage, input.carryover); + if (decision.action === "return") { + return { + handled: true, + selection: decision.selection, + retryAudit + }; + } + } + + const primaryAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, null); + const primaryDecision = evaluateAddressLane(primaryAddressLane, input.addressInputMessage, null); + if (primaryDecision.action === "return") { + return { + handled: true, + selection: primaryDecision.selection, + retryAudit + }; + } + + if (!input.shouldPreferContextualLane && input.carryover?.followupContext) { + const contextualAddressLane = await input.runAddressLaneAttempt(input.addressInputMessage, input.carryover); + const contextualDecision = evaluateAddressLane(contextualAddressLane, input.addressInputMessage, input.carryover); + if (contextualDecision.action === "return") { + return { + handled: true, + selection: contextualDecision.selection, + retryAudit + }; + } + } + + const pendingLimitedSelection = pendingLimited; + if (pendingLimitedSelection && input.canRetryWithRawUserMessage) { + retryAudit.attempted = true; + retryAudit.reason = "limited_result_retry_with_raw_message"; + retryAudit.initial_limited_category = limitedCategory( + (pendingLimitedSelection as AssistantAddressLaneSelection | null)?.addressLane ?? null + ); + retryAudit.retry_message = input.userMessage; + + if (input.carryover?.followupContext) { + retryAudit.retry_used_followup_context = true; + const rawContextualLane = await input.runAddressLaneAttempt(input.userMessage, input.carryover); + const rawContextualDecision = evaluateAddressLane(rawContextualLane, input.userMessage, input.carryover); + if (rawContextualDecision.action === "return") { + retryAudit.retry_result_category = limitedCategory(rawContextualDecision.selection.addressLane); + return { + handled: true, + selection: rawContextualDecision.selection, + retryAudit + }; + } + } + + const rawPrimaryLane = await input.runAddressLaneAttempt(input.userMessage, null); + retryAudit.retry_result_category = limitedCategory(rawPrimaryLane); + const rawPrimaryDecision = evaluateAddressLane(rawPrimaryLane, input.userMessage, null); + if (rawPrimaryDecision.action === "return") { + return { + handled: true, + selection: rawPrimaryDecision.selection, + retryAudit + }; + } + } + + if (pendingLimited) { + return { + handled: true, + selection: pendingLimited, + retryAudit + }; + } + + return { + handled: false, + selection: null, + retryAudit + }; +} diff --git a/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts new file mode 100644 index 0000000..0db697a --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantAddressOrchestrationRuntimeAdapter.ts @@ -0,0 +1,133 @@ +export interface BuildAssistantAddressOrchestrationRuntimeInput { + userMessage: string; + sessionItems: unknown[]; + llmProvider: unknown; + useMock: boolean; + featureAddressLlmPredecomposeV1: boolean; + runAddressLlmPreDecompose: () => Promise>; + buildAddressLlmPredecomposeContractV1: (input: { + sourceMessage: string; + canonicalMessage: string; + }) => unknown; + sanitizeAddressMessageForFallback: (userMessage: string) => string; + toNonEmptyString: (value: unknown) => string | null; + resolveAddressFollowupCarryoverContext: ( + userMessage: string, + sessionItems: unknown[], + addressInputMessage: string, + addressPreDecompose: Record + ) => AssistantAddressCarryoverLike | null; + resolveAssistantOrchestrationDecision: (input: { + rawUserMessage: string; + effectiveAddressUserMessage: string; + followupContext: unknown; + llmPreDecomposeMeta: Record; + useMock: boolean; + }) => Record; + buildAddressDialogContinuationContractV2: ( + userMessage: string, + addressInputMessage: string, + carryover: AssistantAddressCarryoverLike | null, + addressPreDecompose: Record + ) => unknown; +} + +export interface AssistantAddressCarryoverLike { + followupContext?: unknown; + [key: string]: unknown; +} + +export interface BuildAssistantAddressOrchestrationRuntimeOutput { + addressPreDecompose: Record; + addressInputMessage: string; + carryover: AssistantAddressCarryoverLike | null; + orchestrationDecision: Record; + addressRuntimeMeta: Record; + livingModeDecision: { + mode: unknown; + reason: unknown; + }; +} + +function fallbackAddressPreDecompose( + userMessage: string, + llmProvider: unknown, + buildAddressLlmPredecomposeContractV1: BuildAssistantAddressOrchestrationRuntimeInput["buildAddressLlmPredecomposeContractV1"], + sanitizeAddressMessageForFallback: BuildAssistantAddressOrchestrationRuntimeInput["sanitizeAddressMessageForFallback"] +): Record { + const provider = + llmProvider === "local" ? "local" : llmProvider === "openai" ? "openai" : null; + return { + attempted: false, + applied: false, + provider, + traceId: null, + effectiveMessage: userMessage, + reason: "disabled_by_feature_flag", + llmCanonicalCandidateDetected: false, + predecomposeContract: buildAddressLlmPredecomposeContractV1({ + sourceMessage: userMessage, + canonicalMessage: userMessage + }), + fallbackRuleHit: null, + sanitizedUserMessage: sanitizeAddressMessageForFallback(userMessage), + toolGateDecision: null, + toolGateReason: null + }; +} + +export async function buildAssistantAddressOrchestrationRuntime( + input: BuildAssistantAddressOrchestrationRuntimeInput +): Promise { + const addressPreDecompose = input.featureAddressLlmPredecomposeV1 + ? await input.runAddressLlmPreDecompose() + : fallbackAddressPreDecompose( + input.userMessage, + input.llmProvider, + input.buildAddressLlmPredecomposeContractV1, + input.sanitizeAddressMessageForFallback + ); + + const addressInputMessage = + input.toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? input.userMessage; + const carryover = input.resolveAddressFollowupCarryoverContext( + input.userMessage, + input.sessionItems, + addressInputMessage, + addressPreDecompose + ); + const followupContext = carryover?.followupContext ?? null; + const orchestrationDecision = input.resolveAssistantOrchestrationDecision({ + rawUserMessage: input.userMessage, + effectiveAddressUserMessage: addressInputMessage, + followupContext, + llmPreDecomposeMeta: addressPreDecompose, + useMock: input.useMock + }); + const dialogContinuationContract = input.buildAddressDialogContinuationContractV2( + input.userMessage, + addressInputMessage, + carryover, + addressPreDecompose + ); + const addressRuntimeMeta = { + ...addressPreDecompose, + toolGateDecision: orchestrationDecision.toolGateDecision ?? null, + toolGateReason: orchestrationDecision.toolGateReason ?? null, + dialogContinuationContract, + orchestrationContract: orchestrationDecision.orchestrationContract ?? null + }; + const livingModeDecision = { + mode: orchestrationDecision.livingMode, + reason: orchestrationDecision.livingReason + }; + + return { + addressPreDecompose, + addressInputMessage, + carryover, + orchestrationDecision, + addressRuntimeMeta, + livingModeDecision + }; +} diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index b4b78a6..337c7da 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -29,6 +29,8 @@ import * as assistantDeepTurnGroundingRuntimeAdapter_1 from "./assistantDeepTurn import * as assistantDeepTurnPackagingRuntimeAdapter_1 from "./assistantDeepTurnPackagingRuntimeAdapter"; import * as assistantDeepTurnPlanRuntimeAdapter_1 from "./assistantDeepTurnPlanRuntimeAdapter"; import * as assistantDeepTurnRetrievalRuntimeAdapter_1 from "./assistantDeepTurnRetrievalRuntimeAdapter"; +import * as assistantAddressLaneRuntimeAdapter_1 from "./assistantAddressLaneRuntimeAdapter"; +import * as assistantAddressOrchestrationRuntimeAdapter_1 from "./assistantAddressOrchestrationRuntimeAdapter"; import * as assistantLivingChatTurnFinalizeRuntimeAdapter_1 from "./assistantLivingChatTurnFinalizeRuntimeAdapter"; import * as assistantLivingChatRuntimeAdapter_1 from "./assistantLivingChatRuntimeAdapter"; import * as assistantQueryPlanning_1 from "./assistantQueryPlanning"; @@ -4515,47 +4517,27 @@ export class AssistantService { }; let addressRuntimeMetaForDeep = null; if (config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_V1) { - const addressPreDecompose = config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 - ? await runAddressLlmPreDecompose(this.normalizerService, payload, userMessage) - : { - attempted: false, - applied: false, - provider: payload?.llmProvider === "local" ? "local" : payload?.llmProvider === "openai" ? "openai" : null, - traceId: null, - effectiveMessage: userMessage, - reason: "disabled_by_feature_flag", - llmCanonicalCandidateDetected: false, - predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ - sourceMessage: userMessage, - canonicalMessage: userMessage - }), - fallbackRuleHit: null, - sanitizedUserMessage: sanitizeAddressMessageForFallback(userMessage), - toolGateDecision: null, - toolGateReason: null - }; - const addressInputMessage = toNonEmptyString(addressPreDecompose?.effectiveMessage) ?? userMessage; - const carryover = resolveAddressFollowupCarryoverContext(userMessage, session.items, addressInputMessage, addressPreDecompose); - const orchestrationDecision = resolveAssistantOrchestrationDecision({ - rawUserMessage: userMessage, - effectiveAddressUserMessage: addressInputMessage, - followupContext: carryover?.followupContext ?? null, - llmPreDecomposeMeta: addressPreDecompose, - useMock: Boolean(payload.useMock) + const addressOrchestrationRuntime = await (0, assistantAddressOrchestrationRuntimeAdapter_1.buildAssistantAddressOrchestrationRuntime)({ + userMessage, + sessionItems: session.items, + llmProvider: payload?.llmProvider, + useMock: Boolean(payload.useMock), + featureAddressLlmPredecomposeV1: config_1.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1, + runAddressLlmPreDecompose: async () => runAddressLlmPreDecompose(this.normalizerService, payload, userMessage), + buildAddressLlmPredecomposeContractV1: predecomposeContract_1.buildAddressLlmPredecomposeContractV1, + sanitizeAddressMessageForFallback, + toNonEmptyString, + resolveAddressFollowupCarryoverContext, + resolveAssistantOrchestrationDecision, + buildAddressDialogContinuationContractV2 }); - const dialogContinuationContract = buildAddressDialogContinuationContractV2(userMessage, addressInputMessage, carryover, addressPreDecompose); - const addressRuntimeMeta = { - ...addressPreDecompose, - toolGateDecision: orchestrationDecision.toolGateDecision, - toolGateReason: orchestrationDecision.toolGateReason, - dialogContinuationContract, - orchestrationContract: orchestrationDecision.orchestrationContract - }; + const addressPreDecompose = addressOrchestrationRuntime.addressPreDecompose; + const addressInputMessage = addressOrchestrationRuntime.addressInputMessage; + const carryover = addressOrchestrationRuntime.carryover; + const orchestrationDecision = addressOrchestrationRuntime.orchestrationDecision; + const addressRuntimeMeta = addressOrchestrationRuntime.addressRuntimeMeta; addressRuntimeMetaForDeep = addressRuntimeMeta; - const livingModeDecision = { - mode: orchestrationDecision.livingMode, - reason: orchestrationDecision.livingReason - }; + const livingModeDecision = addressOrchestrationRuntime.livingModeDecision; if (!orchestrationDecision.runAddressLane) { (0, log_1.logJson)({ timestamp: new Date().toISOString(), @@ -4592,42 +4574,6 @@ export class AssistantService { const analysisDateHint = runtimeAnalysisContext.as_of_date ?? toNonEmptyString(payload?.context?.period_hint); const canRetryWithRawUserMessage = compactWhitespace(String(addressInputMessage ?? "").toLowerCase()) !== compactWhitespace(String(userMessage ?? "").toLowerCase()); - const retryAudit = { - attempted: false, - reason: null, - initial_limited_category: null, - retry_message: null, - retry_used_followup_context: false, - retry_result_category: null - }; - const withRetryMeta = () => ({ - ...addressRuntimeMeta, - addressRetryAudit: { ...retryAudit } - }); - let pendingLimited = null; - const evaluateAddressLane = (addressLane, messageUsed, carryMeta) => { - if (!addressLane?.handled) { - return null; - } - if (!isRetryableAddressLimitedResult(addressLane)) { - return { - action: "return", - addressLane, - messageUsed, - carryMeta - }; - } - if (!pendingLimited) { - pendingLimited = { - addressLane, - messageUsed, - carryMeta - }; - } - return { - action: "continue" - }; - }; const runAddressLaneAttempt = async (messageUsed, carryMeta) => { const scopedFollowupContext = mergeFollowupContextWithOrganizationScope(carryMeta?.followupContext ?? null, sessionOrganizationScope.activeOrganization); if (scopedFollowupContext) { @@ -4640,48 +4586,20 @@ export class AssistantService { analysisDateHint }); }; - if (shouldPreferContextualLane) { - const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover); - const decision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover); - if (decision?.action === "return") { - return finalizeAddressLaneResponse(decision.addressLane, decision.messageUsed, decision.carryMeta, withRetryMeta()); - } - } - const primaryAddressLane = await runAddressLaneAttempt(addressInputMessage, null); - const primaryDecision = evaluateAddressLane(primaryAddressLane, addressInputMessage, null); - if (primaryDecision?.action === "return") { - return finalizeAddressLaneResponse(primaryDecision.addressLane, primaryDecision.messageUsed, primaryDecision.carryMeta, withRetryMeta()); - } - if (!shouldPreferContextualLane && carryover?.followupContext) { - const contextualAddressLane = await runAddressLaneAttempt(addressInputMessage, carryover); - const contextualDecision = evaluateAddressLane(contextualAddressLane, addressInputMessage, carryover); - if (contextualDecision?.action === "return") { - return finalizeAddressLaneResponse(contextualDecision.addressLane, contextualDecision.messageUsed, contextualDecision.carryMeta, withRetryMeta()); - } - } - if (pendingLimited && canRetryWithRawUserMessage) { - retryAudit.attempted = true; - retryAudit.reason = "limited_result_retry_with_raw_message"; - retryAudit.initial_limited_category = pendingLimited.addressLane?.debug?.limited_reason_category ?? null; - retryAudit.retry_message = userMessage; - if (carryover?.followupContext) { - retryAudit.retry_used_followup_context = true; - const rawContextualLane = await runAddressLaneAttempt(userMessage, carryover); - const rawContextualDecision = evaluateAddressLane(rawContextualLane, userMessage, carryover); - if (rawContextualDecision?.action === "return") { - retryAudit.retry_result_category = rawContextualDecision.addressLane?.debug?.limited_reason_category ?? null; - return finalizeAddressLaneResponse(rawContextualDecision.addressLane, rawContextualDecision.messageUsed, rawContextualDecision.carryMeta, withRetryMeta()); - } - } - const rawPrimaryLane = await runAddressLaneAttempt(userMessage, null); - retryAudit.retry_result_category = rawPrimaryLane?.debug?.limited_reason_category ?? null; - const rawPrimaryDecision = evaluateAddressLane(rawPrimaryLane, userMessage, null); - if (rawPrimaryDecision?.action === "return") { - return finalizeAddressLaneResponse(rawPrimaryDecision.addressLane, rawPrimaryDecision.messageUsed, rawPrimaryDecision.carryMeta, withRetryMeta()); - } - } - if (pendingLimited) { - return finalizeAddressLaneResponse(pendingLimited.addressLane, pendingLimited.messageUsed, pendingLimited.carryMeta, withRetryMeta()); + const addressLaneRuntime = await (0, assistantAddressLaneRuntimeAdapter_1.runAssistantAddressLaneRuntime)({ + userMessage, + addressInputMessage, + carryover, + shouldPreferContextualLane, + canRetryWithRawUserMessage, + runAddressLaneAttempt, + isRetryableAddressLimitedResult + }); + if (addressLaneRuntime.handled && addressLaneRuntime.selection) { + return finalizeAddressLaneResponse(addressLaneRuntime.selection.addressLane, addressLaneRuntime.selection.messageUsed, addressLaneRuntime.selection.carryMeta, { + ...addressRuntimeMeta, + addressRetryAudit: { ...addressLaneRuntime.retryAudit } + }); } } } diff --git a/llm_normalizer/backend/tests/assistantAddressLaneRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressLaneRuntimeAdapter.test.ts new file mode 100644 index 0000000..ded2e4b --- /dev/null +++ b/llm_normalizer/backend/tests/assistantAddressLaneRuntimeAdapter.test.ts @@ -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); + }); +}); + diff --git a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts new file mode 100644 index 0000000..77a9cdf --- /dev/null +++ b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildAssistantAddressOrchestrationRuntime } from "../src/services/assistantAddressOrchestrationRuntimeAdapter"; + +function buildInput(overrides: Record = {}) { + 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"); + }); +}); +