From cf17404925dece68552b6c2558e6a6691ee1ba80 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sat, 18 Apr 2026 21:08:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A7=20=D0=90=D0=9F11=20-=20?= =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=81=D0=B0:=20=D0=90=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D0=B0:=20=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20anchor=20=D0=B8=20temporal=20carryover=20=D0=B2?= =?UTF-8?q?=20continuity=20policy=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D1=8F=D0=BD=D1=83=D1=82=D1=8C=20=D0=B8=D1=85=20=D0=B2=20transi?= =?UTF-8?q?tion=20hot=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ontinuity_stabilization_plan_2026-04-17.md | 10 ++ .../services/assistantContinuityPolicy.js | 54 ++++++++ .../assistantLivingChatRuntimeAdapter.js | 88 +------------ .../services/assistantTransitionPolicy.js | 23 ++-- .../src/services/assistantContinuityPolicy.ts | 73 +++++++++++ .../assistantLivingChatRuntimeAdapter.ts | 89 +------------ .../src/services/assistantTransitionPolicy.ts | 35 +++-- .../tests/assistantContinuityPolicy.test.ts | 25 ++++ .../tests/assistantTransitionPolicy.test.ts | 35 +++++ ..._saved_session_runtime_job-0Xii7XMUFP.json | 120 ++++++++++++++++++ 10 files changed, 348 insertions(+), 204 deletions(-) create mode 100644 llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-0Xii7XMUFP.json 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 cb2debc..1c0c91c 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 @@ -341,6 +341,16 @@ Still open after the accepted phase12 replay: - this matters because deterministic memory-recap and historical-inventory capability replies now depend on the same context interpretation as the rest of continuity policy, rather than on a separate local parser that could drift on root-frame-only turns; - targeted continuity / memory-recap / living-chat tests now protect the root-frame fallback path explicitly; - wide saved-session replay `address_truth_harness_phase12_wider_saved_session_pool_live_20260418_rerun6` remains accepted `20/20`, which is the critical proof that this context-helper convergence did not reopen the broader living-chat continuity path. +- the next cleanup pass also removes one more class of false owners from the living-chat adapter itself: + - `assistantLivingChatRuntimeAdapter` no longer keeps local dead history scanners for grounded inventory / selected-object / generic address debug lookup that are not part of the active execution path anymore; + - this does not change runtime behavior directly, but it reduces the chance that future fixes accidentally revive or patch a stale local owner instead of the shared continuity / memory-recap policy seam; + - targeted living-chat adapter tests and backend build remain green after the cleanup, which is the necessary proof that this was a structural owner-reduction pass rather than a hidden behavior change. +- the next continuity-authority pass now removes one more local `addressDebug -> carryover anchor/date` parser from the transition hot path: + - `assistantContinuityPolicy` now exposes shared helpers for `anchorType/anchorValue` resolution and raw temporal carryover scope (`as_of_date / period_from / period_to`) from grounded `addressDebug`, including root-frame fallback; + - `assistantTransitionPolicy` now consumes these helpers instead of rebuilding previous anchor selection from raw `anchor_value_*` and filter fields inline, and instead of reading carryover dates directly from `readAddressDebugFilters(...)` in multiple ad hoc places; + - this matters because follow-up carryover is now closer to the same continuity interpretation layer that already owns item / organization / scoped-date facts, rather than keeping a separate transition-local parser for the same runtime evidence; + - targeted continuity and transition regressions now protect inferred anchor carryover when explicit `anchor_type` is absent, plus root-frame temporal fallback at the helper layer; + - wide saved-session replay `address_truth_harness_phase12_wider_saved_session_pool_live_20260418_rerun7` remains accepted `20/20`, which is the critical proof that this transition-layer convergence did not reopen the broader saved-session path. ## Next Execution Slice (2026-04-18) diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 820d4d5..091e849 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -5,6 +5,8 @@ exports.readAddressDebugFilters = readAddressDebugFilters; exports.readAddressDebugItem = readAddressDebugItem; exports.readAddressDebugOrganization = readAddressDebugOrganization; exports.readAddressDebugScopedDate = readAddressDebugScopedDate; +exports.readAddressDebugTemporalScope = readAddressDebugTemporalScope; +exports.resolveAddressDebugAnchorContext = resolveAddressDebugAnchorContext; exports.resolveAddressDebugContextFacts = resolveAddressDebugContextFacts; exports.buildInventoryRootFrameFromAddressDebug = buildInventoryRootFrameFromAddressDebug; exports.isGroundedAddressDebug = isGroundedAddressDebug; @@ -60,6 +62,58 @@ function readAddressDebugScopedDate(debug) { formatIsoDateForReply(rootFrameContext?.as_of_date) ?? formatIsoDateForReply(extractedFilters?.period_to)); } +function readAddressDebugTemporalScope(debug, toNonEmptyString = fallbackToNonEmptyString) { + const extractedFilters = readAddressDebugFilters(debug); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + return { + asOfDate: toNonEmptyString(extractedFilters?.as_of_date) ?? toNonEmptyString(rootFrameContext?.as_of_date), + periodFrom: toNonEmptyString(extractedFilters?.period_from) ?? toNonEmptyString(rootFrameContext?.period_from), + periodTo: toNonEmptyString(extractedFilters?.period_to) ?? toNonEmptyString(rootFrameContext?.period_to) + }; +} +function resolveAddressDebugAnchorContext(debug, toNonEmptyString = fallbackToNonEmptyString) { + const explicitAnchorType = toNonEmptyString(debug?.anchor_type); + const explicitAnchorValue = toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw); + if (explicitAnchorType || explicitAnchorValue) { + return { + anchorType: explicitAnchorType, + anchorValue: explicitAnchorValue + }; + } + const extractedFilters = readAddressDebugFilters(debug); + const item = toNonEmptyString(extractedFilters?.item); + if (item) { + return { + anchorType: "item", + anchorValue: item + }; + } + const counterparty = toNonEmptyString(extractedFilters?.counterparty); + if (counterparty) { + return { + anchorType: "counterparty", + anchorValue: counterparty + }; + } + const account = toNonEmptyString(extractedFilters?.account); + if (account) { + return { + anchorType: "account", + anchorValue: account + }; + } + const contract = toNonEmptyString(extractedFilters?.contract); + if (contract) { + return { + anchorType: "contract", + anchorValue: contract + }; + } + return { + anchorType: null, + anchorValue: null + }; +} function resolveAddressDebugContextFacts(debug, toNonEmptyString = fallbackToNonEmptyString) { return { item: readAddressDebugItem(debug, toNonEmptyString), diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index 7d3a906..901ef8d 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -3,69 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.runAssistantLivingChatRuntime = runAssistantLivingChatRuntime; const assistantMemoryRecapPolicy_1 = require("./assistantMemoryRecapPolicy"); const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy"); -function formatIsoDateForReply(value) { - const source = String(value ?? "").trim(); - const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) { - return null; - } - return `${match[3]}.${match[2]}.${match[1]}`; -} -function findLastGroundedInventoryAddressDebug(items) { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" - ? debug.answer_grounding_check - : null; - const groundingStatus = String(answerGroundingCheck?.status ?? ""); - const detectedIntent = String(debug.detected_intent ?? ""); - const capabilityId = String(debug.capability_id ?? ""); - const rootFrameContext = debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" - ? debug.address_root_frame_context - : null; - const rootIntent = String(rootFrameContext?.root_intent ?? ""); - const isInventoryContext = detectedIntent === "inventory_on_hand_as_of_date" || - capabilityId === "confirmed_inventory_on_hand_as_of_date" || - rootIntent === "inventory_on_hand_as_of_date"; - if (groundingStatus === "grounded" && isInventoryContext) { - return debug; - } - } - return null; -} -function findLastAddressDebugWithItem(items) { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - if (String(debug.execution_lane ?? "") !== "address_query") { - continue; - } - const extractedFilters = debug.extracted_filters && typeof debug.extracted_filters === "object" - ? debug.extracted_filters - : null; - const itemLabel = String(extractedFilters?.item ?? "").trim() || - (String(debug.anchor_type ?? "") === "item" - ? String(debug.anchor_value_resolved ?? debug.anchor_value_raw ?? "").trim() - : ""); - if (itemLabel) { - return debug; - } - } - return null; -} function hasPriorAssistantTurn(items) { if (!Array.isArray(items)) { return false; @@ -75,21 +12,6 @@ function hasPriorAssistantTurn(items) { function buildDeterministicSmalltalkLeadReply() { return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e."; } -function findLastAddressDebug(items) { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - if (String(item.debug.execution_lane ?? "") === "address_query") { - return item.debug; - } - } - return null; -} function buildInventoryHistoryCapabilityFollowupReply(input) { const rootFrameContext = input.addressDebug?.address_root_frame_context && typeof input.addressDebug.address_root_frame_context === "object" ? input.addressDebug.address_root_frame_context @@ -100,8 +22,8 @@ function buildInventoryHistoryCapabilityFollowupReply(input) { const organization = input.organization ?? input.toNonEmptyString(rootFrameContext?.organization) ?? input.toNonEmptyString(extractedFilters?.organization); - const lastAsOfDate = formatIsoDateForReply(rootFrameContext?.as_of_date) ?? - formatIsoDateForReply(extractedFilters?.as_of_date); + const lastAsOfDate = (0, assistantContinuityPolicy_1.formatIsoDateForReply)(rootFrameContext?.as_of_date) ?? + (0, assistantContinuityPolicy_1.formatIsoDateForReply)(extractedFilters?.as_of_date); const organizationPart = organization ? ` по компании «${organization}»` : ""; const referenceLine = lastAsOfDate ? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.` @@ -132,9 +54,9 @@ function buildAddressMemoryRecapReply(input) { const organization = input.organization ?? input.toNonEmptyString(extractedFilters?.organization) ?? input.toNonEmptyString(rootFrameContext?.organization); - const scopedDate = formatIsoDateForReply(extractedFilters?.as_of_date) ?? - formatIsoDateForReply(rootFrameContext?.as_of_date) ?? - formatIsoDateForReply(extractedFilters?.period_to); + const scopedDate = (0, assistantContinuityPolicy_1.formatIsoDateForReply)(extractedFilters?.as_of_date) ?? + (0, assistantContinuityPolicy_1.formatIsoDateForReply)(rootFrameContext?.as_of_date) ?? + (0, assistantContinuityPolicy_1.formatIsoDateForReply)(extractedFilters?.period_to); if (item) { const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; const organizationPart = organization ? ` по компании «${organization}»` : ""; diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 86a0dbe..fea0756 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -340,6 +340,7 @@ function createAssistantTransitionPolicy(deps) { mergeKnownOrganizations: deps.mergeKnownOrganizations }); const continuitySnapshot = organizationAuthority.continuitySnapshot; + const continuityTemporalScope = (0, assistantContinuityPolicy_1.readAddressDebugTemporalScope)(continuitySnapshot.lastGroundedAddressDebug, deps.toNonEmptyString); const organizationClarificationCandidates = Array.isArray(organizationAuthority.organizationClarificationCandidates) ? organizationAuthority.organizationClarificationCandidates : []; @@ -522,13 +523,9 @@ function createAssistantTransitionPolicy(deps) { followupSelectionMode = "switch_to_suggested_intent"; } } - let previousAnchorType = deps.toNonEmptyString(previousAddressDebug.anchor_type); - let previousAnchor = deps.toNonEmptyString(previousAddressDebug.anchor_value_resolved) ?? - deps.toNonEmptyString(previousAddressDebug.anchor_value_raw) ?? - deps.readAddressFilterString(previousAddressDebug, "item") ?? - deps.readAddressFilterString(previousAddressDebug, "counterparty") ?? - deps.readAddressFilterString(previousAddressDebug, "account") ?? - deps.readAddressFilterString(previousAddressDebug, "contract"); + const previousAnchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(previousAddressDebug, deps.toNonEmptyString); + let previousAnchorType = previousAnchorContext.anchorType; + let previousAnchor = previousAnchorContext.anchorValue; const navigationSessionContext = addressNavigationState && typeof addressNavigationState === "object" ? addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" ? addressNavigationState.session_context @@ -699,8 +696,8 @@ function createAssistantTransitionPolicy(deps) { } if (shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.as_of_date) && - (0, assistantContinuityPolicy_1.readAddressDebugFilters)(continuitySnapshot.lastGroundedAddressDebug)?.as_of_date) { - previousFilters.as_of_date = deps.toNonEmptyString((0, assistantContinuityPolicy_1.readAddressDebugFilters)(continuitySnapshot.lastGroundedAddressDebug)?.as_of_date); + continuityTemporalScope.asOfDate) { + previousFilters.as_of_date = continuityTemporalScope.asOfDate; } if (shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.period_from) && @@ -709,8 +706,8 @@ function createAssistantTransitionPolicy(deps) { } if (shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.period_from) && - (0, assistantContinuityPolicy_1.readAddressDebugFilters)(continuitySnapshot.lastGroundedAddressDebug)?.period_from) { - previousFilters.period_from = deps.toNonEmptyString((0, assistantContinuityPolicy_1.readAddressDebugFilters)(continuitySnapshot.lastGroundedAddressDebug)?.period_from); + continuityTemporalScope.periodFrom) { + previousFilters.period_from = continuityTemporalScope.periodFrom; } if (shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.period_to) && @@ -719,8 +716,8 @@ function createAssistantTransitionPolicy(deps) { } if (shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.period_to) && - (0, assistantContinuityPolicy_1.readAddressDebugFilters)(continuitySnapshot.lastGroundedAddressDebug)?.period_to) { - previousFilters.period_to = deps.toNonEmptyString((0, assistantContinuityPolicy_1.readAddressDebugFilters)(continuitySnapshot.lastGroundedAddressDebug)?.period_to); + continuityTemporalScope.periodTo) { + previousFilters.period_to = continuityTemporalScope.periodTo; } const rootContextOnlyPivot = Boolean((deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) && diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 5730ec2..fea9f61 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -33,6 +33,17 @@ export interface AssistantAddressDebugContextFacts { scopedDate: string | null; } +export interface AssistantAddressDebugTemporalScope { + asOfDate: string | null; + periodFrom: string | null; + periodTo: string | null; +} + +export interface AssistantAddressDebugAnchorContext { + anchorType: string | null; + anchorValue: string | null; +} + export interface AssistantOrganizationAuthorityInput { sessionItems?: unknown[]; sessionKnownOrganizations?: unknown[]; @@ -124,6 +135,68 @@ export function readAddressDebugScopedDate(debug: Record | null ); } +export function readAddressDebugTemporalScope( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): AssistantAddressDebugTemporalScope { + const extractedFilters = readAddressDebugFilters(debug); + const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + return { + asOfDate: toNonEmptyString(extractedFilters?.as_of_date) ?? toNonEmptyString(rootFrameContext?.as_of_date), + periodFrom: toNonEmptyString(extractedFilters?.period_from) ?? toNonEmptyString(rootFrameContext?.period_from), + periodTo: toNonEmptyString(extractedFilters?.period_to) ?? toNonEmptyString(rootFrameContext?.period_to) + }; +} + +export function resolveAddressDebugAnchorContext( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): AssistantAddressDebugAnchorContext { + const explicitAnchorType = toNonEmptyString(debug?.anchor_type); + const explicitAnchorValue = + toNonEmptyString(debug?.anchor_value_resolved) ?? toNonEmptyString(debug?.anchor_value_raw); + if (explicitAnchorType || explicitAnchorValue) { + return { + anchorType: explicitAnchorType, + anchorValue: explicitAnchorValue + }; + } + + const extractedFilters = readAddressDebugFilters(debug); + const item = toNonEmptyString(extractedFilters?.item); + if (item) { + return { + anchorType: "item", + anchorValue: item + }; + } + const counterparty = toNonEmptyString(extractedFilters?.counterparty); + if (counterparty) { + return { + anchorType: "counterparty", + anchorValue: counterparty + }; + } + const account = toNonEmptyString(extractedFilters?.account); + if (account) { + return { + anchorType: "account", + anchorValue: account + }; + } + const contract = toNonEmptyString(extractedFilters?.contract); + if (contract) { + return { + anchorType: "contract", + anchorValue: contract + }; + } + return { + anchorType: null, + anchorValue: null + }; +} + export function resolveAddressDebugContextFacts( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 4fd1fb4..9aa3cd6 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -3,7 +3,7 @@ import { buildInventoryHistoryCapabilityFollowupReply as buildInventoryHistoryCapabilityFollowupReplyFromPolicy, resolveAssistantLivingChatMemoryContext } from "./assistantMemoryRecapPolicy"; -import { resolveAssistantOrganizationAuthority } from "./assistantContinuityPolicy"; +import { formatIsoDateForReply, resolveAssistantOrganizationAuthority } from "./assistantContinuityPolicy"; export interface AssistantLivingChatSessionScopeInput { knownOrganizations?: unknown[]; @@ -66,77 +66,6 @@ export interface AssistantLivingChatRuntimeOutput { debug: Record | null; } -function formatIsoDateForReply(value: unknown): string | null { - const source = String(value ?? "").trim(); - const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) { - return null; - } - return `${match[3]}.${match[2]}.${match[1]}`; -} - -function findLastGroundedInventoryAddressDebug(items: unknown[]): Record | null { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] as { role?: string; debug?: Record } | null; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - const answerGroundingCheck = - debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" - ? (debug.answer_grounding_check as Record) - : null; - const groundingStatus = String(answerGroundingCheck?.status ?? ""); - const detectedIntent = String(debug.detected_intent ?? ""); - const capabilityId = String(debug.capability_id ?? ""); - const rootFrameContext = - debug.address_root_frame_context && typeof debug.address_root_frame_context === "object" - ? (debug.address_root_frame_context as Record) - : null; - const rootIntent = String(rootFrameContext?.root_intent ?? ""); - const isInventoryContext = - detectedIntent === "inventory_on_hand_as_of_date" || - capabilityId === "confirmed_inventory_on_hand_as_of_date" || - rootIntent === "inventory_on_hand_as_of_date"; - if (groundingStatus === "grounded" && isInventoryContext) { - return debug; - } - } - return null; -} - -function findLastAddressDebugWithItem(items: unknown[]): Record | null { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] as { role?: string; debug?: Record } | null; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - const debug = item.debug; - if (String(debug.execution_lane ?? "") !== "address_query") { - continue; - } - const extractedFilters = - debug.extracted_filters && typeof debug.extracted_filters === "object" - ? (debug.extracted_filters as Record) - : null; - const itemLabel = - String(extractedFilters?.item ?? "").trim() || - (String(debug.anchor_type ?? "") === "item" - ? String(debug.anchor_value_resolved ?? debug.anchor_value_raw ?? "").trim() - : ""); - if (itemLabel) { - return debug; - } - } - return null; -} - function hasPriorAssistantTurn(items: unknown[]): boolean { if (!Array.isArray(items)) { return false; @@ -148,22 +77,6 @@ function buildDeterministicSmalltalkLeadReply(): string { return "\u041f\u0440\u0438\u0432\u0435\u0442! \u0412\u0441\u0451 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e."; } -function findLastAddressDebug(items: unknown[]): Record | null { - if (!Array.isArray(items)) { - return null; - } - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] as { role?: string; debug?: Record } | null; - if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { - continue; - } - if (String(item.debug.execution_lane ?? "") === "address_query") { - return item.debug; - } - } - return null; -} - function buildInventoryHistoryCapabilityFollowupReply(input: { organization: string | null; addressDebug: Record | null; diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index b2e5cb8..0f80934 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -3,6 +3,8 @@ import { buildInventoryRootFrameFromAddressDebug, readAddressDebugFilters, readAddressDebugItem, + readAddressDebugTemporalScope, + resolveAddressDebugAnchorContext, resolveAssistantOrganizationAuthority } from "./assistantContinuityPolicy"; @@ -422,6 +424,10 @@ export function createAssistantTransitionPolicy(deps) { mergeKnownOrganizations: deps.mergeKnownOrganizations }); const continuitySnapshot = organizationAuthority.continuitySnapshot; + const continuityTemporalScope = readAddressDebugTemporalScope( + continuitySnapshot.lastGroundedAddressDebug, + deps.toNonEmptyString + ); const organizationClarificationCandidates = Array.isArray(organizationAuthority.organizationClarificationCandidates) ? organizationAuthority.organizationClarificationCandidates : []; @@ -652,14 +658,9 @@ export function createAssistantTransitionPolicy(deps) { followupSelectionMode = "switch_to_suggested_intent"; } } - let previousAnchorType = deps.toNonEmptyString(previousAddressDebug.anchor_type); - let previousAnchor = - deps.toNonEmptyString(previousAddressDebug.anchor_value_resolved) ?? - deps.toNonEmptyString(previousAddressDebug.anchor_value_raw) ?? - deps.readAddressFilterString(previousAddressDebug, "item") ?? - deps.readAddressFilterString(previousAddressDebug, "counterparty") ?? - deps.readAddressFilterString(previousAddressDebug, "account") ?? - deps.readAddressFilterString(previousAddressDebug, "contract"); + const previousAnchorContext = resolveAddressDebugAnchorContext(previousAddressDebug, deps.toNonEmptyString); + let previousAnchorType = previousAnchorContext.anchorType; + let previousAnchor = previousAnchorContext.anchorValue; const navigationSessionContext = addressNavigationState && typeof addressNavigationState === "object" ? addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" @@ -851,11 +852,9 @@ export function createAssistantTransitionPolicy(deps) { if ( shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.as_of_date) && - readAddressDebugFilters(continuitySnapshot.lastGroundedAddressDebug)?.as_of_date + continuityTemporalScope.asOfDate ) { - previousFilters.as_of_date = deps.toNonEmptyString( - readAddressDebugFilters(continuitySnapshot.lastGroundedAddressDebug)?.as_of_date - ); + previousFilters.as_of_date = continuityTemporalScope.asOfDate; } if ( shouldBackfillPreviousDateScopeFromNavigation && @@ -867,11 +866,9 @@ export function createAssistantTransitionPolicy(deps) { if ( shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.period_from) && - readAddressDebugFilters(continuitySnapshot.lastGroundedAddressDebug)?.period_from + continuityTemporalScope.periodFrom ) { - previousFilters.period_from = deps.toNonEmptyString( - readAddressDebugFilters(continuitySnapshot.lastGroundedAddressDebug)?.period_from - ); + previousFilters.period_from = continuityTemporalScope.periodFrom; } if ( shouldBackfillPreviousDateScopeFromNavigation && @@ -883,11 +880,9 @@ export function createAssistantTransitionPolicy(deps) { if ( shouldBackfillPreviousDateScopeFromNavigation && !deps.toNonEmptyString(previousFilters.period_to) && - readAddressDebugFilters(continuitySnapshot.lastGroundedAddressDebug)?.period_to + continuityTemporalScope.periodTo ) { - previousFilters.period_to = deps.toNonEmptyString( - readAddressDebugFilters(continuitySnapshot.lastGroundedAddressDebug)?.period_to - ); + previousFilters.period_to = continuityTemporalScope.periodTo; } const rootContextOnlyPivot = Boolean( (deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index 5904179..b7e3d88 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + readAddressDebugTemporalScope, resolveAddressDebugContextFacts, + resolveAddressDebugAnchorContext, resolveAssistantOrganizationAuthority } from "../src/services/assistantContinuityPolicy"; @@ -75,4 +77,27 @@ describe("assistantContinuityPolicy organization authority", () => { scopedDate: "31.03.2020" }); }); + + it("resolves carryover temporal scope and inferred anchor from debug filters when explicit anchor fields are absent", () => { + const debug = { + extracted_filters: { + item: "Рабочая станция", + period_from: "2020-03-01" + }, + address_root_frame_context: { + as_of_date: "2020-03-31", + period_to: "2020-03-31" + } + }; + + expect(readAddressDebugTemporalScope(debug)).toEqual({ + asOfDate: "2020-03-31", + periodFrom: "2020-03-01", + periodTo: "2020-03-31" + }); + expect(resolveAddressDebugAnchorContext(debug)).toEqual({ + anchorType: "item", + anchorValue: "Рабочая станция" + }); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index de087ca..7eeb3b8 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -304,6 +304,41 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date"); }); + it("hydrates carryover anchor from shared debug helpers when explicit anchor fields are absent", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Подтвержденный складской срез собран.", + debug: { + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + item: "Рабочая станция", + period_from: "2020-03-01" + }, + address_root_frame_context: { + as_of_date: "2020-03-31", + period_to: "2020-03-31" + }, + anchor_type: null, + anchor_value_resolved: null + } + }), + hasAddressFollowupContextSignal: () => true, + hasReferentialPointer: () => true, + findRecentInventoryRootFrame: () => null, + findRecentAddressFilterValue: () => null, + resolveAddressIntent: () => ({ intent: "unknown" }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext("по этой позиции", [], null, null, null); + + expect(carryover?.followupContext?.previous_anchor_type).toBe("item"); + expect(carryover?.followupContext?.previous_anchor_value).toBe("Рабочая станция"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + item: "Рабочая станция", + period_from: "2020-03-01" + }); + }); + it("bridges selected-item purchase provenance into a VAT period follow-up", () => { const item = "Рабочая станция универсального специалиста"; const policy = buildPolicy({ diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-0Xii7XMUFP.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-0Xii7XMUFP.json new file mode 100644 index 0000000..7b7966a --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-0Xii7XMUFP.json @@ -0,0 +1,120 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-0Xii7XMUFP", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "приветик - че как там дела" + }, + { + "user_message": "расскажи что можешь интересного" + }, + { + "user_message": "кайф - что там на складе по остаткам?" + }, + { + "user_message": "АЛЬТЕРНАТИВА" + }, + { + "user_message": "а исторические остатки на другие даты умеешь?" + }, + { + "user_message": "давай на июль 2017" + }, + { + "user_message": "март 2016" + }, + { + "user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?" + }, + { + "user_message": "а кому продали?" + }, + { + "user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?" + }, + { + "user_message": "ндс можешь прикинуть на дату покупки рабочей станции?" + }, + { + "user_message": "а какой ндс мы должны сгрузить на март 2020?" + }, + { + "user_message": "прикинь какой ндс нам надо заплатить на февраль 2017" + }, + { + "user_message": "кто у нас самый доходный клиент за все время" + }, + { + "user_message": "кто нам должен денег на май 2017" + }, + { + "user_message": "а какой ндс мы должны примерно заплатить за этот период?" + }, + { + "user_message": "мы должны комуто денег на сегодня?" + }, + { + "user_message": "а нам?" + }, + { + "user_message": "какой у нас самый доходный год" + }, + { + "user_message": "а за 2017 мы скок заработали?" + }, + { + "user_message": "сколько вообще денег мы заработали за все время?" + }, + { + "user_message": "ты умеешь считать дельту по договорам?" + }, + { + "user_message": "по чепурнову покажи все доки" + }, + { + "user_message": "а по свк" + }, + { + "user_message": "а сейчас у нас есть что на складе?" + }, + { + "user_message": "что нам отгружал чепурнов? какой товар или услугу?" + }, + { + "user_message": "какие остатки на складе на сегодня" + }, + { + "user_message": "остатки на март 2016" + }, + { + "user_message": "хвосты покажи по счету 60 на август 2022" + }, + { + "user_message": "Есть ли остатки товара, которые закупались очень давно" + }, + { + "user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020" + }, + { + "user_message": "а по Альтернативе Плюс сколько лет активности в базе 1С?" + }, + { + "user_message": "Как ты оценишь деятельность компании?" + } + ] + } + ] +} \ No newline at end of file