diff --git a/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md index 29d9d3c..8ee4a83 100644 --- a/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md +++ b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md @@ -406,6 +406,15 @@ Still open after the accepted phase12 replay: - the adapter now imports and uses only the shared builders from `assistantMemoryRecapPolicy`, which makes the live chat branch structurally closer to a single owner for grounded contextual replies; - targeted `assistantLivingChatRuntimeAdapter` and `assistantMemoryRecapPolicy` tests stay green after the cleanup, and backend build remains green; - live reruns on `phase14` and `phase15` on `2026-04-19` surfaced partial top-level status only because the packs still pin `inventory today` expectations to `2026-04-18`; the repaired contextual reply contours themselves stayed semantically clean, which confirms this pass as owner reduction rather than a new runtime regression. +- the next continuity-authority pass now removes one more duplicate root-frame owner from `assistantService` follow-up glue: + - `assistantService.extractAddressCarryoverAnchor(...)` no longer reconstructs anchor resolution from raw `anchor_value_* / extracted_filters` using its own local precedence order; + - `assistantService.findRecentInventoryRootFrame(...)` no longer rebuilds inventory root carryover from `detected_intent + extracted_filters` as a separate local parser; + - both seams now consume the shared continuity helpers: + - `resolveAddressDebugAnchorContext(...)` + - `buildInventoryRootFrameFromAddressDebug(...)` + - this matters because follow-up carryover in the top-level service now reads the same root-frame authority that already owns `root_filters / root_anchor / current_frame_kind`, instead of keeping a service-local fallback that could silently prefer drilldown `extracted_filters` over the real `address_root_frame_context`; + - targeted `assistantAddressFollowupContext` and `addressInventoryRootFrameRegression` suites are green after the move, including a new regression that explicitly proves `root_filters` come from `address_root_frame_context.root_filters` rather than from stale drilldown `extracted_filters`; + - this pass strengthens continuity convergence in the top-level orchestration glue without introducing a new case-specific branch. ## Next Execution Slice (2026-04-18) diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index f553862..42450bc 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2454,15 +2454,7 @@ function extractAddressCarryoverAnchor(addressDebug) { anchorValue: null }; } - return { - anchorType: toNonEmptyString(addressDebug.anchor_type), - anchorValue: toNonEmptyString(addressDebug.anchor_value_resolved) ?? - toNonEmptyString(addressDebug.anchor_value_raw) ?? - readAddressInventoryItemFilter(addressDebug) ?? - readAddressFilterString(addressDebug, "counterparty") ?? - readAddressFilterString(addressDebug, "contract") ?? - readAddressFilterString(addressDebug, "account") - }; + return (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); } function findRecentInventoryRootFrame(items) { for (let index = items.length - 1; index >= 0; index -= 1) { @@ -2474,20 +2466,15 @@ function findRecentInventoryRootFrame(items) { if (!isAddressLaneDebugPayload(debug)) { continue; } - const detectedIntent = toNonEmptyString(debug.detected_intent); - if (!isInventoryRootFrameIntent(detectedIntent)) { + const rootFrame = (0, assistantContinuityPolicy_1.buildInventoryRootFrameFromAddressDebug)(debug, toNonEmptyString); + if (!rootFrame) { continue; } - const anchor = extractAddressCarryoverAnchor(debug); - const filtersRaw = debug.extracted_filters; - const filters = filtersRaw && typeof filtersRaw === "object" - ? { ...filtersRaw } - : {}; return { - intent: detectedIntent, - filters, - anchorType: anchor.anchorType, - anchorValue: anchor.anchorValue, + intent: rootFrame.intent, + filters: { ...(rootFrame.filters ?? {}) }, + anchorType: rootFrame.anchorType, + anchorValue: rootFrame.anchorValue, messageId: toNonEmptyString(item.message_id) }; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 17dd227..5ce928d 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2409,15 +2409,7 @@ function extractAddressCarryoverAnchor(addressDebug) { anchorValue: null }; } - return { - anchorType: toNonEmptyString(addressDebug.anchor_type), - anchorValue: toNonEmptyString(addressDebug.anchor_value_resolved) ?? - toNonEmptyString(addressDebug.anchor_value_raw) ?? - readAddressInventoryItemFilter(addressDebug) ?? - readAddressFilterString(addressDebug, "counterparty") ?? - readAddressFilterString(addressDebug, "contract") ?? - readAddressFilterString(addressDebug, "account") - }; + return (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); } function findRecentInventoryRootFrame(items) { for (let index = items.length - 1; index >= 0; index -= 1) { @@ -2429,20 +2421,15 @@ function findRecentInventoryRootFrame(items) { if (!isAddressLaneDebugPayload(debug)) { continue; } - const detectedIntent = toNonEmptyString(debug.detected_intent); - if (!isInventoryRootFrameIntent(detectedIntent)) { + const rootFrame = (0, assistantContinuityPolicy_1.buildInventoryRootFrameFromAddressDebug)(debug, toNonEmptyString); + if (!rootFrame) { continue; } - const anchor = extractAddressCarryoverAnchor(debug); - const filtersRaw = debug.extracted_filters; - const filters = filtersRaw && typeof filtersRaw === "object" - ? { ...filtersRaw } - : {}; return { - intent: detectedIntent, - filters, - anchorType: anchor.anchorType, - anchorValue: anchor.anchorValue, + intent: rootFrame.intent, + filters: { ...(rootFrame.filters ?? {}) }, + anchorType: rootFrame.anchorType, + anchorValue: rootFrame.anchorValue, messageId: toNonEmptyString(item.message_id) }; } diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index dfa1a29..44dee8b 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -2492,6 +2492,109 @@ describe("assistant address follow-up carryover", () => { expect(normalizerService.normalize).not.toHaveBeenCalled(); }); + it("restores inventory root filters from address_root_frame_context instead of drilldown extracted filters", async () => { + const calls: Array<{ message: string; options?: any }> = []; + const followupMessage = "остатки на эту же дату"; + const itemLabel = "Рабочая станция универсального специалиста"; + const organization = 'ООО "Альтернатива Плюс"'; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === followupMessage && options?.followupContext) { + return buildAddressLaneResult({ + reply_text: "Собран складской срез на дату из root frame.", + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + organization, + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] + } + }); + } + return null; + }) + } as any; + + const normalizerService = { + normalize: vi.fn(async () => ({ + assistant_reply: "normalizer_fallback_should_not_be_used", + reply_type: "partial_coverage", + debug: {} + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const sessionId = `asst-address-root-frame-authority-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-root-frame-authority-seed", + session_id: sessionId, + role: "assistant", + text: "inventory purchase documents seed", + reply_type: "factual", + created_at: "2026-04-19T08:55:00.000Z", + trace_id: "address-root-frame-authority-seed", + debug: { + detected_mode: "address_query", + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + item: itemLabel, + organization, + as_of_date: "2021-04-15" + }, + selected_recipe: "address_inventory_purchase_documents_for_item_v1", + anchor_type: "item", + anchor_value_raw: itemLabel, + anchor_value_resolved: itemLabel, + address_root_frame_context: { + root_intent: "inventory_on_hand_as_of_date", + root_filters: { + organization, + warehouse: "Основной склад", + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + root_anchor_type: "organization", + root_anchor_value: organization, + current_frame_kind: "inventory_drilldown" + } + } + } as any); + + const second = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(second.ok).toBe(true); + expect(second.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].options?.followupContext?.root_context_only).toBe(true); + expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); + expect(calls[0].options?.followupContext?.root_filters?.organization).toBe(organization); + expect(calls[0].options?.followupContext?.root_filters?.warehouse).toBe("Основной склад"); + expect(calls[0].options?.followupContext?.root_filters?.as_of_date).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.root_filters?.period_from).toBe("2021-03-01"); + expect(calls[0].options?.followupContext?.root_filters?.period_to).toBe("2021-03-31"); + expect(calls[0].options?.followupContext?.root_filters?.as_of_date).not.toBe("2021-04-15"); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); + it("treats colloquial supplier follow-up from an inventory root slice as continuation of the focused item", async () => { const calls: Array<{ message: string; options?: any }> = []; const followupMessage = "у кого мы модуль прямоугольный купили кстати";