From 15fa643fc8b88e71d57073f8e76cfe7d78a682ab Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 17 Apr 2026 11:39:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A7=20=D0=90=D0=9F11=20-=20?= =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20meta=20=D0=B8=20me?= =?UTF-8?q?mory=20recap=20policy=20=D0=B8=D0=B7=20route=20runtime=20=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20Phase=205=20?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=BC=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._truth_harness_phase5_meta_memory_mix.json | 106 +++++++ .../assistantLivingChatRuntimeAdapter.js | 21 +- .../services/assistantMemoryRecapPolicy.js | 203 ++++++++++++ .../services/assistantMetaFollowupPolicy.js | 68 ++++ .../dist/services/assistantRoutePolicy.js | 97 +++--- .../backend/dist/services/assistantService.js | 22 +- .../assistantLivingChatRuntimeAdapter.ts | 27 +- .../services/assistantMemoryRecapPolicy.ts | 295 ++++++++++++++++++ .../services/assistantMetaFollowupPolicy.ts | 140 +++++++++ .../src/services/assistantRoutePolicy.ts | 104 +++--- .../backend/src/services/assistantService.ts | 22 +- .../tests/assistantMemoryRecapPolicy.test.ts | 90 ++++++ .../tests/assistantMetaFollowupPolicy.test.ts | 75 +++++ .../tests/assistantRoutePolicy.test.ts | 44 ++- .../data/autorun_generators/history.json | 34 ++ ..._20260417083044_gen-ag04170830-5f771d.json | 93 ++++++ ..._20260417083044_gen-ag04170830-5f771d.json | 43 +++ 17 files changed, 1342 insertions(+), 142 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase5_meta_memory_mix.json create mode 100644 llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js create mode 100644 llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js create mode 100644 llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts create mode 100644 llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts create mode 100644 llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts create mode 100644 llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts create mode 100644 llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417083044_gen-ag04170830-5f771d.json create mode 100644 llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417083044_gen-ag04170830-5f771d.json diff --git a/docs/orchestration/address_truth_harness_phase5_meta_memory_mix.json b/docs/orchestration/address_truth_harness_phase5_meta_memory_mix.json new file mode 100644 index 0000000..db0051e --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase5_meta_memory_mix.json @@ -0,0 +1,106 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase5_meta_memory_mix", + "domain": "address_phase5_meta_memory_mix", + "title": "Phase 5 meta and memory recap replay over interrupted address context", + "description": "Targeted replay for meta and memory recap policy extraction: inventory root, historical capability follow-up, data-scope meta interrupt, selected-object provenance, capability meta interrupt, and deterministic memory recap.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_inventory_root_march_2021", + "title": "Inventory root establishes grounded March 2021 context", + "question": "какие остатки на складе на март 2021", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "2021-03-31", + "period_from": "2021-03-01", + "period_to": "2021-03-31" + }, + "required_direct_answer_patterns_any": [ + "31\\.03\\.2021", + "(?i)на складе" + ] + }, + { + "step_id": "step_02_inventory_history_capability_followup", + "title": "Historical capability follow-up stays contextual and human", + "question": "а исторические остатки тоже можешь?", + "required_answer_patterns_any": [ + "(?i)историческ", + "(?i)могу", + "(?i)март 2020|июнь 2016|2017" + ], + "forbidden_answer_patterns": [ + "(?i)^сейчас не дам прямой адресный ответ", + "(?i)^в текущем адресном контуре этот запрос лучше не закрывать в лоб" + ] + }, + { + "step_id": "step_03_data_scope_meta_interrupt", + "title": "Data-scope meta question stays deterministic and non-technical", + "question": "по какой компании мы сейчас работаем?", + "required_answer_patterns_any": [ + "(?i)компан|организац|контур", + "(?i)работ", + "(?i)альтернатив|доступн|выбран" + ], + "forbidden_answer_patterns": [ + "(?i)tool_gate_reason", + "(?i)hard_meta_mode", + "(?i)living_router_reason" + ] + }, + { + "step_id": "step_04_selected_item_supplier", + "title": "Selected-object provenance survives the meta interrupt", + "question": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "required_direct_answer_patterns_any": [ + "(?i)столешница 600\\*3050\\*26 альмандин", + "(?i)поставщик|поставил|куплен", + "(?i)союз|торговый дом" + ], + "forbidden_direct_answer_patterns": [ + "(?i)^на 31\\.03\\.2021 на складе", + "(?i)^сейчас не дам прямой адресный ответ" + ] + }, + { + "step_id": "step_05_capability_meta_interrupt", + "title": "Capability meta question does not break the address context", + "question": "что ты умеешь?", + "required_answer_patterns_any": [ + "(?i)могу|умею", + "(?i)остатк|документ|контрагент|ндс" + ], + "forbidden_answer_patterns": [ + "(?i)tool_gate_reason", + "(?i)address_mode" + ] + }, + { + "step_id": "step_06_memory_recap_after_interrupts", + "title": "Memory recap still remembers the selected object after meta interruptions", + "question": "а ты помнишь, что мы по этой позиции уже выяснили?", + "required_answer_patterns_any": [ + "(?i)помню", + "(?i)столешница 600\\*3050\\*26 альмандин", + "(?i)позици" + ], + "forbidden_answer_patterns": [ + "(?i)^сейчас не дам прямой адресный ответ", + "(?i)^в текущем адресном контуре этот запрос лучше не закрывать в лоб" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index 689941b..cb79c77 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.runAssistantLivingChatRuntime = runAssistantLivingChatRuntime; +const assistantMemoryRecapPolicy_1 = require("./assistantMemoryRecapPolicy"); function formatIsoDateForReply(value) { const source = String(value ?? "").trim(); const match = source.match(/^(\d{4})-(\d{2})-(\d{2})$/); @@ -159,14 +160,14 @@ async function runAssistantLivingChatRuntime(input) { let knownOrganizations = input.mergeKnownOrganizations(input.sessionScope.knownOrganizations ?? []); let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization); let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization); - const contextualInventoryHistoryCapabilityFollowup = input.modeDecision?.reason === "inventory_history_capability_followup_detected"; - const contextualMemoryRecapFollowup = input.modeDecision?.reason === "memory_recap_followup_detected"; - const lastGroundedInventoryAddressDebug = contextualInventoryHistoryCapabilityFollowup - ? findLastGroundedInventoryAddressDebug(input.sessionItems) - : null; - const lastMemoryAddressDebug = contextualMemoryRecapFollowup - ? findLastAddressDebugWithItem(input.sessionItems) ?? findLastAddressDebug(input.sessionItems) - : null; + const memoryRecapContext = (0, assistantMemoryRecapPolicy_1.resolveAssistantLivingChatMemoryContext)({ + modeDecisionReason: input.modeDecision?.reason ?? null, + sessionItems: input.sessionItems + }); + const contextualInventoryHistoryCapabilityFollowup = memoryRecapContext.contextualInventoryHistoryCapabilityFollowup; + const contextualMemoryRecapFollowup = memoryRecapContext.contextualMemoryRecapFollowup; + const lastGroundedInventoryAddressDebug = memoryRecapContext.lastGroundedInventoryAddressDebug; + const lastMemoryAddressDebug = memoryRecapContext.lastMemoryAddressDebug; if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { chatText = input.buildAssistantSafetyRefusalReply(); livingChatSource = "deterministic_safety_refusal"; @@ -212,7 +213,7 @@ async function runAssistantLivingChatRuntime(input) { } else if (contextualInventoryHistoryCapabilityFollowup) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildInventoryHistoryCapabilityFollowupReply({ + chatText = (0, assistantMemoryRecapPolicy_1.buildInventoryHistoryCapabilityFollowupReply)({ organization: scopedOrganization, addressDebug: lastGroundedInventoryAddressDebug, toNonEmptyString: input.toNonEmptyString @@ -222,7 +223,7 @@ async function runAssistantLivingChatRuntime(input) { } else if (contextualMemoryRecapFollowup) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAddressMemoryRecapReply({ + chatText = (0, assistantMemoryRecapPolicy_1.buildAddressMemoryRecapReply)({ organization: scopedOrganization, addressDebug: lastMemoryAddressDebug, toNonEmptyString: input.toNonEmptyString diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js new file mode 100644 index 0000000..b455969 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -0,0 +1,203 @@ +"use strict"; +// @ts-nocheck +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildInventoryHistoryCapabilityFollowupReply = buildInventoryHistoryCapabilityFollowupReply; +exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply; +exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext; +exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy; +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 collectMessageSamples(input) { + const values = [ + input.rawUserMessage, + input.repairedRawUserMessage, + input.effectiveAddressUserMessage, + input.repairedEffectiveAddressUserMessage + ]; + return Array.from(new Set(values + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0))); +} +function hasSignalAcrossSamples(samples, detector) { + return samples.some((sample) => detector(sample)); +} +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 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 + : null; + const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? input.addressDebug.extracted_filters + : null; + 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 organizationPart = organization ? ` по компании «${organization}»` : ""; + const referenceLine = lastAsOfDate + ? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.` + : `Да, могу показать исторические данные${organizationPart} в этом же складском контуре.`; + return [ + referenceLine, + `Могу показать исторические остатки${organizationPart} за нужный месяц, дату или год.`, + "Например:", + "- `на март 2020`", + "- `на июнь 2016`", + "- `за 2017 год`", + "- `сравни июнь 2016 с текущим срезом`", + "Если хочешь, сразу покажу нужный исторический период." + ].join("\n"); +} +function buildAddressMemoryRecapReply(input) { + const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? input.addressDebug.extracted_filters + : null; + const rootFrameContext = input.addressDebug?.address_root_frame_context && + typeof input.addressDebug.address_root_frame_context === "object" + ? input.addressDebug.address_root_frame_context + : null; + const item = input.toNonEmptyString(extractedFilters?.item) ?? + (String(input.addressDebug?.anchor_type ?? "") === "item" + ? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ?? + input.toNonEmptyString(input.addressDebug?.anchor_value_raw) + : null); + 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); + if (item) { + const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; + const organizationPart = organization ? ` по компании «${organization}»` : ""; + return [ + `Да, помню. Мы обсуждали позицию «${item}»${organizationPart}${datePart}.`, + "Могу продолжить по ней без переписывания сущности: кто поставил, когда купили, по каким документам или кому продали." + ].join(" "); + } + if (organization || scopedDate) { + const organizationPart = organization ? ` по компании «${organization}»` : ""; + const datePart = scopedDate ? ` на ${scopedDate}` : ""; + return [ + `Да, помню. Мы уже смотрели адресный контур${organizationPart}${datePart}.`, + "Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию." + ].join(" "); + } + return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; +} +function resolveAssistantLivingChatMemoryContext(input) { + const contextualInventoryHistoryCapabilityFollowup = String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected"; + const contextualMemoryRecapFollowup = String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected"; + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + return { + contextualInventoryHistoryCapabilityFollowup, + contextualMemoryRecapFollowup, + lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup + ? findLastGroundedInventoryAddressDebug(sessionItems) + : null, + lastMemoryAddressDebug: contextualMemoryRecapFollowup + ? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems) + : null + }; +} +function createAssistantMemoryRecapPolicy(deps) { + function resolveRouteMemorySignals(input) { + const samples = collectMessageSamples(input); + const historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal); + const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal); + return { + contextualHistoricalCapabilityFollowupDetected: Boolean(input.capabilityMetaQuery && + !input.dataScopeMetaQuery && + !input.dataRetrievalSignal && + historicalCapabilitySignal && + deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug)), + contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery && + !input.capabilityMetaQuery && + !input.dataRetrievalSignal && + !input.strongDataSignal && + !input.aggregateBusinessAnalyticsSignal && + memoryRecapSignal && + input.hasPriorAddressDebug) + }; + } + return { + resolveRouteMemorySignals + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js b/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js new file mode 100644 index 0000000..d17285a --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js @@ -0,0 +1,68 @@ +"use strict"; +// @ts-nocheck +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAssistantMetaFollowupPolicy = createAssistantMetaFollowupPolicy; +function collectMessageSamples(input) { + const values = [ + input.rawUserMessage, + input.repairedRawUserMessage, + input.effectiveAddressUserMessage, + input.repairedEffectiveAddressUserMessage + ]; + return Array.from(new Set(values + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0))); +} +function hasSignalAcrossSamples(samples, detector) { + return samples.some((sample) => detector(sample)); +} +function hasImplicitHistoricalCapabilityMetaSignal(samples) { + return samples.some((sample) => /(?:историческ|история|архив|раньше|ретро|старые\s+данные)/iu.test(sample) && + /(?:мож(?:ешь|ем|но)|уме(?:ешь|ете))/iu.test(sample)); +} +function createAssistantMetaFollowupPolicy(deps) { + function resolveMetaSignalSet(input) { + const samples = collectMessageSamples(input); + if (samples.length === 0) { + return { + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + metaAnswerFollowupSignal: false + }; + } + return { + dataScopeMetaQuery: hasSignalAcrossSamples(samples, deps.hasAssistantDataScopeMetaQuestionSignal), + capabilityMetaQuery: hasSignalAcrossSamples(samples, deps.shouldHandleAsAssistantCapabilityMetaQuery) || + hasImplicitHistoricalCapabilityMetaSignal(samples), + metaAnswerFollowupSignal: hasSignalAcrossSamples(samples, deps.hasMetaAnswerFollowupSignal) + }; + } + function resolveHardMetaMode(input) { + if (Boolean(input.dataScopeMetaQuery)) { + return "data_scope"; + } + if (Boolean(input.capabilityMetaQuery) && !Boolean(input.dataRetrievalSignal)) { + return "capability"; + } + return null; + } + function isMetaFollowupOverGroundedAnswer(input) { + return Boolean(input.followupContext && + input.hasPriorAddressAnswerContext && + (input.metaAnswerFollowupSignal || input.vatEvaluativeFollowupSignal) && + !input.dataScopeMetaQuery && + !input.capabilityMetaQuery && + !input.aggregateBusinessAnalyticsSignal && + !input.dataRetrievalSignal && + !input.strongDataSignal && + String(input.resolvedMode ?? "") !== "address_query" && + String(input.resolvedIntent ?? "") === "unknown" && + (!input.llmContractIntent || String(input.llmContractIntent) === "unknown") && + String(input.llmContractMode ?? "") !== "address_query"); + } + return { + resolveMetaSignalSet, + resolveHardMetaMode, + isMetaFollowupOverGroundedAnswer + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 1d737d8..2dfe365 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -46,7 +46,7 @@ function shouldBypassStrictDeepInvestigationCueForAddressIntent(intent) { return Boolean(intent && ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS.has(intent)); } function createAssistantRoutePolicy(deps) { - const { repairAddressMojibake, findLastGroundedAddressAnswerDebug, findLastOrganizationClarificationAddressDebug, mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, hasAssistantDataScopeMetaQuestionSignal, shouldHandleAsAssistantCapabilityMetaQuery, hasDataRetrievalRequestSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, hasOpenContractsAddressSignal, detectAddressQuestionMode, resolveAddressIntent, toNonEmptyString, hasStrictDeepInvestigationCue, hasStrongDataIntentSignal, hasAccountingSignal, hasDangerOrCoercionSignal, hasAddressFollowupContextSignal, hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, hasHistoricalCapabilityFollowupSignal, isGroundedInventoryContextDebug, hasConversationMemoryRecallFollowupSignal, findLastAddressAssistantItem, hasMetaAnswerFollowupSignal, resolveAddressToolGateDecision, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision } = deps; + const { repairAddressMojibake, findLastGroundedAddressAnswerDebug, findLastOrganizationClarificationAddressDebug, mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, resolveMetaSignalSet, resolveHardMetaMode, isMetaFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, hasOpenContractsAddressSignal, detectAddressQuestionMode, resolveAddressIntent, toNonEmptyString, hasStrictDeepInvestigationCue, hasStrongDataIntentSignal, hasAccountingSignal, hasDangerOrCoercionSignal, hasAddressFollowupContextSignal, hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, isGroundedInventoryContextDebug, resolveRouteMemorySignals, findLastAddressAssistantItem, resolveAddressToolGateDecision, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision } = deps; function resolveAssistantOrchestrationDecision(input) { const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage); @@ -78,14 +78,14 @@ function createAssistantRoutePolicy(deps) { organizationClarificationCandidates.some((candidate) => normalizeOrganizationScopeValue(candidate) === organizationClarificationSelectionFromScope) ? organizationClarificationSelectionFromScope : null); - const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawUserMessage) || - hasAssistantDataScopeMetaQuestionSignal(repairedRawUserMessage) || - hasAssistantDataScopeMetaQuestionSignal(effectiveAddressUserMessage) || - hasAssistantDataScopeMetaQuestionSignal(repairedEffectiveAddressUserMessage); - const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawUserMessage) || - shouldHandleAsAssistantCapabilityMetaQuery(repairedRawUserMessage) || - shouldHandleAsAssistantCapabilityMetaQuery(effectiveAddressUserMessage) || - shouldHandleAsAssistantCapabilityMetaQuery(repairedEffectiveAddressUserMessage); + const metaSignals = resolveMetaSignalSet({ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage + }); + const dataScopeMetaQuery = metaSignals.dataScopeMetaQuery; + const capabilityMetaQuery = metaSignals.capabilityMetaQuery; const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawUserMessage) || hasDataRetrievalRequestSignal(repairedRawUserMessage) || hasDataRetrievalRequestSignal(effectiveAddressUserMessage) || @@ -191,30 +191,29 @@ function createAssistantRoutePolicy(deps) { (llmFirstUnsupportedCandidate || llmContractMode === null) && !protectedInventoryShortFollowup && !organizationClarificationContinuationDetected); - const contextualHistoricalCapabilityFollowupDetected = Boolean(capabilityMetaQuery && - !dataScopeMetaQuery && - !dataRetrievalSignal && - (hasHistoricalCapabilityFollowupSignal(rawUserMessage) || - hasHistoricalCapabilityFollowupSignal(repairedRawUserMessage) || - hasHistoricalCapabilityFollowupSignal(effectiveAddressUserMessage) || - hasHistoricalCapabilityFollowupSignal(repairedEffectiveAddressUserMessage)) && - isGroundedInventoryContextDebug(lastGroundedAddressDebug)); - const contextualMemoryRecapFollowupDetected = Boolean(!dataScopeMetaQuery && - !capabilityMetaQuery && - !dataRetrievalSignal && - !strongDataSignal && - !aggregateBusinessAnalyticsSignal && - (hasConversationMemoryRecallFollowupSignal(rawUserMessage) || - hasConversationMemoryRecallFollowupSignal(repairedRawUserMessage) || - hasConversationMemoryRecallFollowupSignal(effectiveAddressUserMessage) || - hasConversationMemoryRecallFollowupSignal(repairedEffectiveAddressUserMessage)) && - (lastGroundedAddressDebug || - findLastAddressAssistantItem(sessionItems)?.debug)); - const hardMetaMode = dataScopeMetaQuery - ? "data_scope" - : capabilityMetaQuery && !dataRetrievalSignal - ? "capability" - : null; + const lastAddressAssistantDebug = sessionItems + ? findLastAddressAssistantItem(sessionItems)?.debug ?? null + : null; + const memorySignals = resolveRouteMemorySignals({ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage, + dataScopeMetaQuery, + capabilityMetaQuery, + dataRetrievalSignal, + strongDataSignal, + aggregateBusinessAnalyticsSignal, + lastGroundedAddressDebug, + hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug) + }); + const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected; + const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected; + const hardMetaMode = resolveHardMetaMode({ + dataScopeMetaQuery, + capabilityMetaQuery, + dataRetrievalSignal + }); if (hardMetaMode === "data_scope") { return { runAddressLane: false, @@ -355,10 +354,7 @@ function createAssistantRoutePolicy(deps) { } }; } - const metaAnswerFollowupSignal = hasMetaAnswerFollowupSignal(rawUserMessage) || - hasMetaAnswerFollowupSignal(repairedRawUserMessage) || - hasMetaAnswerFollowupSignal(effectiveAddressUserMessage) || - hasMetaAnswerFollowupSignal(repairedEffectiveAddressUserMessage); + const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && @@ -463,18 +459,21 @@ function createAssistantRoutePolicy(deps) { sessionItems })); const hasPriorAddressAnswerContext = Boolean(lastGroundedAddressDebug || toNonEmptyString(followupContext?.previous_intent)); - const metaFollowupOverGroundedAnswer = Boolean(followupContext && - hasPriorAddressAnswerContext && - (metaAnswerFollowupSignal || vatEvaluativeFollowupSignal) && - !dataScopeMetaQuery && - !capabilityMetaQuery && - !aggregateBusinessAnalyticsSignal && - !dataRetrievalSignal && - !strongDataSignal && - resolvedModeDetection.mode !== "address_query" && - resolvedIntentResolution.intent === "unknown" && - (!llmContractIntent || llmContractIntent === "unknown") && - llmContractMode !== "address_query"); + const metaFollowupOverGroundedAnswer = isMetaFollowupOverGroundedAnswer({ + followupContext, + hasPriorAddressAnswerContext, + metaAnswerFollowupSignal, + vatEvaluativeFollowupSignal, + dataScopeMetaQuery, + capabilityMetaQuery, + aggregateBusinessAnalyticsSignal, + dataRetrievalSignal, + strongDataSignal, + resolvedMode: resolvedModeDetection.mode, + resolvedIntent: resolvedIntentResolution.intent, + llmContractIntent, + llmContractMode + }); let runAddressLane = Boolean(baseToolGate?.runAddressLane); let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane"); let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0"); diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 44b869b..86a1355 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -68,6 +68,8 @@ const assistantCoverageGrounding_1 = __importStar(require("./assistantCoverageGr const assistantDeepTurnAttemptRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnAttemptRuntimeAdapter")); const assistantBoundaryPolicy_1 = __importStar(require("./assistantBoundaryPolicy")); const assistantLivingModePolicy_1 = __importStar(require("./assistantLivingModePolicy")); +const assistantMetaFollowupPolicy_1 = __importStar(require("./assistantMetaFollowupPolicy")); +const assistantMemoryRecapPolicy_1 = __importStar(require("./assistantMemoryRecapPolicy")); const assistantRoutePolicy_1 = __importStar(require("./assistantRoutePolicy")); const assistantTransitionPolicy_1 = __importStar(require("./assistantTransitionPolicy")); const assistantOrganizationScopeRuntimeAdapter_1 = __importStar(require("./assistantOrganizationScopeRuntimeAdapter")); @@ -4753,6 +4755,16 @@ const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistan hasAssistantCapabilityQuestionSignal, hasOperationalAdminActionRequestSignal }); +const assistantMetaFollowupPolicy = (0, assistantMetaFollowupPolicy_1.createAssistantMetaFollowupPolicy)({ + hasAssistantDataScopeMetaQuestionSignal: assistantLivingModePolicy.hasAssistantDataScopeMetaQuestionSignal, + shouldHandleAsAssistantCapabilityMetaQuery: assistantLivingModePolicy.shouldHandleAsAssistantCapabilityMetaQuery, + hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal +}); +const assistantMemoryRecapPolicy = (0, assistantMemoryRecapPolicy_1.createAssistantMemoryRecapPolicy)({ + hasHistoricalCapabilityFollowupSignal: assistantLivingModePolicy.hasHistoricalCapabilityFollowupSignal, + hasConversationMemoryRecallFollowupSignal: assistantLivingModePolicy.hasConversationMemoryRecallFollowupSignal, + isGroundedInventoryContextDebug +}); const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePolicy)({ repairAddressMojibake, findLastGroundedAddressAnswerDebug, @@ -4760,8 +4772,9 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, - hasAssistantDataScopeMetaQuestionSignal: assistantLivingModePolicy.hasAssistantDataScopeMetaQuestionSignal, - shouldHandleAsAssistantCapabilityMetaQuery: assistantLivingModePolicy.shouldHandleAsAssistantCapabilityMetaQuery, + resolveMetaSignalSet: assistantMetaFollowupPolicy.resolveMetaSignalSet, + resolveHardMetaMode: assistantMetaFollowupPolicy.resolveHardMetaMode, + isMetaFollowupOverGroundedAnswer: assistantMetaFollowupPolicy.isMetaFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal: assistantLivingModePolicy.hasDataRetrievalRequestSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, @@ -4777,11 +4790,8 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, - hasHistoricalCapabilityFollowupSignal: assistantLivingModePolicy.hasHistoricalCapabilityFollowupSignal, - isGroundedInventoryContextDebug, - hasConversationMemoryRecallFollowupSignal: assistantLivingModePolicy.hasConversationMemoryRecallFollowupSignal, + resolveRouteMemorySignals: assistantMemoryRecapPolicy.resolveRouteMemorySignals, findLastAddressAssistantItem, - hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal, resolveAddressToolGateDecision, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 9b48056..aa26ac8 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -1,3 +1,9 @@ +import { + buildAddressMemoryRecapReply as buildAddressMemoryRecapReplyFromPolicy, + buildInventoryHistoryCapabilityFollowupReply as buildInventoryHistoryCapabilityFollowupReplyFromPolicy, + resolveAssistantLivingChatMemoryContext +} from "./assistantMemoryRecapPolicy"; + export interface AssistantLivingChatSessionScopeInput { knownOrganizations?: unknown[]; selectedOrganization?: unknown; @@ -249,16 +255,15 @@ export async function runAssistantLivingChatRuntime( let knownOrganizations = input.mergeKnownOrganizations(input.sessionScope.knownOrganizations ?? []); let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization); let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization); + const memoryRecapContext = resolveAssistantLivingChatMemoryContext({ + modeDecisionReason: input.modeDecision?.reason ?? null, + sessionItems: input.sessionItems + }); const contextualInventoryHistoryCapabilityFollowup = - input.modeDecision?.reason === "inventory_history_capability_followup_detected"; - const contextualMemoryRecapFollowup = - input.modeDecision?.reason === "memory_recap_followup_detected"; - const lastGroundedInventoryAddressDebug = contextualInventoryHistoryCapabilityFollowup - ? findLastGroundedInventoryAddressDebug(input.sessionItems) - : null; - const lastMemoryAddressDebug = contextualMemoryRecapFollowup - ? findLastAddressDebugWithItem(input.sessionItems) ?? findLastAddressDebug(input.sessionItems) - : null; + memoryRecapContext.contextualInventoryHistoryCapabilityFollowup; + const contextualMemoryRecapFollowup = memoryRecapContext.contextualMemoryRecapFollowup; + const lastGroundedInventoryAddressDebug = memoryRecapContext.lastGroundedInventoryAddressDebug; + const lastMemoryAddressDebug = memoryRecapContext.lastMemoryAddressDebug; if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { chatText = input.buildAssistantSafetyRefusalReply(); @@ -303,7 +308,7 @@ export async function runAssistantLivingChatRuntime( livingChatSource = "deterministic_operational_boundary"; } else if (contextualInventoryHistoryCapabilityFollowup) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildInventoryHistoryCapabilityFollowupReply({ + chatText = buildInventoryHistoryCapabilityFollowupReplyFromPolicy({ organization: scopedOrganization, addressDebug: lastGroundedInventoryAddressDebug, toNonEmptyString: input.toNonEmptyString @@ -312,7 +317,7 @@ export async function runAssistantLivingChatRuntime( livingChatSource = "deterministic_inventory_history_capability_contract"; } else if (contextualMemoryRecapFollowup) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAddressMemoryRecapReply({ + chatText = buildAddressMemoryRecapReplyFromPolicy({ organization: scopedOrganization, addressDebug: lastMemoryAddressDebug, toNonEmptyString: input.toNonEmptyString diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts new file mode 100644 index 0000000..eee78b1 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -0,0 +1,295 @@ +// @ts-nocheck + +export interface ResolveAssistantRouteMemorySignalsInput { + rawUserMessage?: unknown; + repairedRawUserMessage?: unknown; + effectiveAddressUserMessage?: unknown; + repairedEffectiveAddressUserMessage?: unknown; + dataScopeMetaQuery?: boolean; + capabilityMetaQuery?: boolean; + dataRetrievalSignal?: boolean; + strongDataSignal?: boolean; + aggregateBusinessAnalyticsSignal?: boolean; + lastGroundedAddressDebug?: unknown; + hasPriorAddressDebug?: boolean; +} + +export interface AssistantRouteMemorySignals { + contextualHistoricalCapabilityFollowupDetected: boolean; + contextualMemoryRecapFollowupDetected: boolean; +} + +export interface ResolveAssistantLivingChatMemoryContextInput { + modeDecisionReason?: unknown; + sessionItems?: unknown[]; +} + +export interface AssistantLivingChatMemoryContext { + contextualInventoryHistoryCapabilityFollowup: boolean; + contextualMemoryRecapFollowup: boolean; + lastGroundedInventoryAddressDebug: Record | null; + lastMemoryAddressDebug: Record | null; +} + +export interface AssistantMemoryRecapPolicyDeps { + hasHistoricalCapabilityFollowupSignal: (text: unknown) => boolean; + hasConversationMemoryRecallFollowupSignal: (text: unknown) => boolean; + isGroundedInventoryContextDebug: (debug: unknown) => boolean; +} + +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 collectMessageSamples(input: ResolveAssistantRouteMemorySignalsInput): string[] { + const values = [ + input.rawUserMessage, + input.repairedRawUserMessage, + input.effectiveAddressUserMessage, + input.repairedEffectiveAddressUserMessage + ]; + return Array.from( + new Set( + values + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0) + ) + ); +} + +function hasSignalAcrossSamples( + samples: string[], + detector: (text: unknown) => boolean +): boolean { + return samples.some((sample) => detector(sample)); +} + +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 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; +} + +export function buildInventoryHistoryCapabilityFollowupReply(input: { + organization: string | null; + addressDebug: Record | null; + toNonEmptyString: (value: unknown) => string | null; +}): string { + const rootFrameContext = + input.addressDebug?.address_root_frame_context && + typeof input.addressDebug.address_root_frame_context === "object" + ? (input.addressDebug.address_root_frame_context as Record) + : null; + const extractedFilters = + input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? (input.addressDebug.extracted_filters as Record) + : null; + 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 organizationPart = organization ? ` по компании «${organization}»` : ""; + const referenceLine = lastAsOfDate + ? `Да, могу. Сейчас мы уже смотрели складской срез${organizationPart} на ${lastAsOfDate}.` + : `Да, могу показать исторические данные${organizationPart} в этом же складском контуре.`; + return [ + referenceLine, + `Могу показать исторические остатки${organizationPart} за нужный месяц, дату или год.`, + "Например:", + "- `на март 2020`", + "- `на июнь 2016`", + "- `за 2017 год`", + "- `сравни июнь 2016 с текущим срезом`", + "Если хочешь, сразу покажу нужный исторический период." + ].join("\n"); +} + +export function buildAddressMemoryRecapReply(input: { + organization: string | null; + addressDebug: Record | null; + toNonEmptyString: (value: unknown) => string | null; +}): string { + const extractedFilters = + input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? (input.addressDebug.extracted_filters as Record) + : null; + const rootFrameContext = + input.addressDebug?.address_root_frame_context && + typeof input.addressDebug.address_root_frame_context === "object" + ? (input.addressDebug.address_root_frame_context as Record) + : null; + const item = + input.toNonEmptyString(extractedFilters?.item) ?? + (String(input.addressDebug?.anchor_type ?? "") === "item" + ? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ?? + input.toNonEmptyString(input.addressDebug?.anchor_value_raw) + : null); + 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); + + if (item) { + const datePart = scopedDate ? ` в срезе на ${scopedDate}` : ""; + const organizationPart = organization ? ` по компании «${organization}»` : ""; + return [ + `Да, помню. Мы обсуждали позицию «${item}»${organizationPart}${datePart}.`, + "Могу продолжить по ней без переписывания сущности: кто поставил, когда купили, по каким документам или кому продали." + ].join(" "); + } + + if (organization || scopedDate) { + const organizationPart = organization ? ` по компании «${organization}»` : ""; + const datePart = scopedDate ? ` на ${scopedDate}` : ""; + return [ + `Да, помню. Мы уже смотрели адресный контур${organizationPart}${datePart}.`, + "Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию." + ].join(" "); + } + + return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; +} + +export function resolveAssistantLivingChatMemoryContext( + input: ResolveAssistantLivingChatMemoryContextInput +): AssistantLivingChatMemoryContext { + const contextualInventoryHistoryCapabilityFollowup = + String(input.modeDecisionReason ?? "") === "inventory_history_capability_followup_detected"; + const contextualMemoryRecapFollowup = + String(input.modeDecisionReason ?? "") === "memory_recap_followup_detected"; + const sessionItems = Array.isArray(input.sessionItems) ? input.sessionItems : []; + return { + contextualInventoryHistoryCapabilityFollowup, + contextualMemoryRecapFollowup, + lastGroundedInventoryAddressDebug: contextualInventoryHistoryCapabilityFollowup + ? findLastGroundedInventoryAddressDebug(sessionItems) + : null, + lastMemoryAddressDebug: contextualMemoryRecapFollowup + ? findLastAddressDebugWithItem(sessionItems) ?? findLastAddressDebug(sessionItems) + : null + }; +} + +export function createAssistantMemoryRecapPolicy( + deps: AssistantMemoryRecapPolicyDeps +) { + function resolveRouteMemorySignals( + input: ResolveAssistantRouteMemorySignalsInput + ): AssistantRouteMemorySignals { + const samples = collectMessageSamples(input); + const historicalCapabilitySignal = hasSignalAcrossSamples( + samples, + deps.hasHistoricalCapabilityFollowupSignal + ); + const memoryRecapSignal = hasSignalAcrossSamples( + samples, + deps.hasConversationMemoryRecallFollowupSignal + ); + return { + contextualHistoricalCapabilityFollowupDetected: Boolean( + input.capabilityMetaQuery && + !input.dataScopeMetaQuery && + !input.dataRetrievalSignal && + historicalCapabilitySignal && + deps.isGroundedInventoryContextDebug(input.lastGroundedAddressDebug) + ), + contextualMemoryRecapFollowupDetected: Boolean( + !input.dataScopeMetaQuery && + !input.capabilityMetaQuery && + !input.dataRetrievalSignal && + !input.strongDataSignal && + !input.aggregateBusinessAnalyticsSignal && + memoryRecapSignal && + input.hasPriorAddressDebug + ) + }; + } + + return { + resolveRouteMemorySignals + }; +} diff --git a/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts b/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts new file mode 100644 index 0000000..e2fc1eb --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts @@ -0,0 +1,140 @@ +// @ts-nocheck + +export interface ResolveAssistantMetaSignalSetInput { + rawUserMessage?: unknown; + repairedRawUserMessage?: unknown; + effectiveAddressUserMessage?: unknown; + repairedEffectiveAddressUserMessage?: unknown; +} + +export interface ResolveAssistantMetaFollowupOverGroundedAnswerInput { + followupContext?: unknown; + hasPriorAddressAnswerContext?: boolean; + metaAnswerFollowupSignal?: boolean; + vatEvaluativeFollowupSignal?: boolean; + dataScopeMetaQuery?: boolean; + capabilityMetaQuery?: boolean; + aggregateBusinessAnalyticsSignal?: boolean; + dataRetrievalSignal?: boolean; + strongDataSignal?: boolean; + resolvedMode?: unknown; + resolvedIntent?: unknown; + llmContractIntent?: unknown; + llmContractMode?: unknown; +} + +export interface ResolveAssistantHardMetaModeInput { + dataScopeMetaQuery?: boolean; + capabilityMetaQuery?: boolean; + dataRetrievalSignal?: boolean; +} + +export interface AssistantMetaSignalSet { + dataScopeMetaQuery: boolean; + capabilityMetaQuery: boolean; + metaAnswerFollowupSignal: boolean; +} + +export interface AssistantMetaFollowupPolicyDeps { + hasAssistantDataScopeMetaQuestionSignal: (text: unknown) => boolean; + shouldHandleAsAssistantCapabilityMetaQuery: (text: unknown) => boolean; + hasMetaAnswerFollowupSignal: (text: unknown) => boolean; +} + +function collectMessageSamples(input: ResolveAssistantMetaSignalSetInput): string[] { + const values = [ + input.rawUserMessage, + input.repairedRawUserMessage, + input.effectiveAddressUserMessage, + input.repairedEffectiveAddressUserMessage + ]; + return Array.from( + new Set( + values + .map((item) => String(item ?? "").trim()) + .filter((item) => item.length > 0) + ) + ); +} + +function hasSignalAcrossSamples( + samples: string[], + detector: (text: unknown) => boolean +): boolean { + return samples.some((sample) => detector(sample)); +} + +function hasImplicitHistoricalCapabilityMetaSignal(samples: string[]): boolean { + return samples.some( + (sample) => + /(?:историческ|история|архив|раньше|ретро|старые\s+данные)/iu.test(sample) && + /(?:мож(?:ешь|ем|но)|уме(?:ешь|ете))/iu.test(sample) + ); +} + +export function createAssistantMetaFollowupPolicy( + deps: AssistantMetaFollowupPolicyDeps +) { + function resolveMetaSignalSet( + input: ResolveAssistantMetaSignalSetInput + ): AssistantMetaSignalSet { + const samples = collectMessageSamples(input); + if (samples.length === 0) { + return { + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + metaAnswerFollowupSignal: false + }; + } + return { + dataScopeMetaQuery: hasSignalAcrossSamples( + samples, + deps.hasAssistantDataScopeMetaQuestionSignal + ), + capabilityMetaQuery: + hasSignalAcrossSamples(samples, deps.shouldHandleAsAssistantCapabilityMetaQuery) || + hasImplicitHistoricalCapabilityMetaSignal(samples), + metaAnswerFollowupSignal: hasSignalAcrossSamples( + samples, + deps.hasMetaAnswerFollowupSignal + ) + }; + } + + function resolveHardMetaMode( + input: ResolveAssistantHardMetaModeInput + ): "data_scope" | "capability" | null { + if (Boolean(input.dataScopeMetaQuery)) { + return "data_scope"; + } + if (Boolean(input.capabilityMetaQuery) && !Boolean(input.dataRetrievalSignal)) { + return "capability"; + } + return null; + } + + function isMetaFollowupOverGroundedAnswer( + input: ResolveAssistantMetaFollowupOverGroundedAnswerInput + ): boolean { + return Boolean( + input.followupContext && + input.hasPriorAddressAnswerContext && + (input.metaAnswerFollowupSignal || input.vatEvaluativeFollowupSignal) && + !input.dataScopeMetaQuery && + !input.capabilityMetaQuery && + !input.aggregateBusinessAnalyticsSignal && + !input.dataRetrievalSignal && + !input.strongDataSignal && + String(input.resolvedMode ?? "") !== "address_query" && + String(input.resolvedIntent ?? "") === "unknown" && + (!input.llmContractIntent || String(input.llmContractIntent) === "unknown") && + String(input.llmContractMode ?? "") !== "address_query" + ); + } + + return { + resolveMetaSignalSet, + resolveHardMetaMode, + isMetaFollowupOverGroundedAnswer + }; +} diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 07afd48..10ec085 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -50,8 +50,9 @@ export function createAssistantRoutePolicy(deps) { mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, - hasAssistantDataScopeMetaQuestionSignal, - shouldHandleAsAssistantCapabilityMetaQuery, + resolveMetaSignalSet, + resolveHardMetaMode, + isMetaFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, @@ -67,11 +68,9 @@ export function createAssistantRoutePolicy(deps) { hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, - hasHistoricalCapabilityFollowupSignal, isGroundedInventoryContextDebug, - hasConversationMemoryRecallFollowupSignal, + resolveRouteMemorySignals, findLastAddressAssistantItem, - hasMetaAnswerFollowupSignal, resolveAddressToolGateDecision, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, @@ -112,14 +111,14 @@ export function createAssistantRoutePolicy(deps) { organizationClarificationCandidates.some((candidate) => normalizeOrganizationScopeValue(candidate) === organizationClarificationSelectionFromScope) ? organizationClarificationSelectionFromScope : null); - const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(rawUserMessage) || - hasAssistantDataScopeMetaQuestionSignal(repairedRawUserMessage) || - hasAssistantDataScopeMetaQuestionSignal(effectiveAddressUserMessage) || - hasAssistantDataScopeMetaQuestionSignal(repairedEffectiveAddressUserMessage); - const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(rawUserMessage) || - shouldHandleAsAssistantCapabilityMetaQuery(repairedRawUserMessage) || - shouldHandleAsAssistantCapabilityMetaQuery(effectiveAddressUserMessage) || - shouldHandleAsAssistantCapabilityMetaQuery(repairedEffectiveAddressUserMessage); + const metaSignals = resolveMetaSignalSet({ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage + }); + const dataScopeMetaQuery = metaSignals.dataScopeMetaQuery; + const capabilityMetaQuery = metaSignals.capabilityMetaQuery; const dataRetrievalSignal = hasDataRetrievalRequestSignal(rawUserMessage) || hasDataRetrievalRequestSignal(repairedRawUserMessage) || hasDataRetrievalRequestSignal(effectiveAddressUserMessage) || @@ -225,30 +224,29 @@ export function createAssistantRoutePolicy(deps) { (llmFirstUnsupportedCandidate || llmContractMode === null) && !protectedInventoryShortFollowup && !organizationClarificationContinuationDetected); - const contextualHistoricalCapabilityFollowupDetected = Boolean(capabilityMetaQuery && - !dataScopeMetaQuery && - !dataRetrievalSignal && - (hasHistoricalCapabilityFollowupSignal(rawUserMessage) || - hasHistoricalCapabilityFollowupSignal(repairedRawUserMessage) || - hasHistoricalCapabilityFollowupSignal(effectiveAddressUserMessage) || - hasHistoricalCapabilityFollowupSignal(repairedEffectiveAddressUserMessage)) && - isGroundedInventoryContextDebug(lastGroundedAddressDebug)); - const contextualMemoryRecapFollowupDetected = Boolean(!dataScopeMetaQuery && - !capabilityMetaQuery && - !dataRetrievalSignal && - !strongDataSignal && - !aggregateBusinessAnalyticsSignal && - (hasConversationMemoryRecallFollowupSignal(rawUserMessage) || - hasConversationMemoryRecallFollowupSignal(repairedRawUserMessage) || - hasConversationMemoryRecallFollowupSignal(effectiveAddressUserMessage) || - hasConversationMemoryRecallFollowupSignal(repairedEffectiveAddressUserMessage)) && - (lastGroundedAddressDebug || - findLastAddressAssistantItem(sessionItems)?.debug)); - const hardMetaMode = dataScopeMetaQuery - ? "data_scope" - : capabilityMetaQuery && !dataRetrievalSignal - ? "capability" - : null; + const lastAddressAssistantDebug = sessionItems + ? findLastAddressAssistantItem(sessionItems)?.debug ?? null + : null; + const memorySignals = resolveRouteMemorySignals({ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage, + dataScopeMetaQuery, + capabilityMetaQuery, + dataRetrievalSignal, + strongDataSignal, + aggregateBusinessAnalyticsSignal, + lastGroundedAddressDebug, + hasPriorAddressDebug: Boolean(lastGroundedAddressDebug || lastAddressAssistantDebug) + }); + const contextualHistoricalCapabilityFollowupDetected = memorySignals.contextualHistoricalCapabilityFollowupDetected; + const contextualMemoryRecapFollowupDetected = memorySignals.contextualMemoryRecapFollowupDetected; + const hardMetaMode = resolveHardMetaMode({ + dataScopeMetaQuery, + capabilityMetaQuery, + dataRetrievalSignal + }); if (hardMetaMode === "data_scope") { return { runAddressLane: false, @@ -389,10 +387,7 @@ export function createAssistantRoutePolicy(deps) { } }; } - const metaAnswerFollowupSignal = hasMetaAnswerFollowupSignal(rawUserMessage) || - hasMetaAnswerFollowupSignal(repairedRawUserMessage) || - hasMetaAnswerFollowupSignal(effectiveAddressUserMessage) || - hasMetaAnswerFollowupSignal(repairedEffectiveAddressUserMessage); + const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; const baseToolGate = resolveAddressToolGateDecision(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage); const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && @@ -497,18 +492,21 @@ export function createAssistantRoutePolicy(deps) { sessionItems })); const hasPriorAddressAnswerContext = Boolean(lastGroundedAddressDebug || toNonEmptyString(followupContext?.previous_intent)); - const metaFollowupOverGroundedAnswer = Boolean(followupContext && - hasPriorAddressAnswerContext && - (metaAnswerFollowupSignal || vatEvaluativeFollowupSignal) && - !dataScopeMetaQuery && - !capabilityMetaQuery && - !aggregateBusinessAnalyticsSignal && - !dataRetrievalSignal && - !strongDataSignal && - resolvedModeDetection.mode !== "address_query" && - resolvedIntentResolution.intent === "unknown" && - (!llmContractIntent || llmContractIntent === "unknown") && - llmContractMode !== "address_query"); + const metaFollowupOverGroundedAnswer = isMetaFollowupOverGroundedAnswer({ + followupContext, + hasPriorAddressAnswerContext, + metaAnswerFollowupSignal, + vatEvaluativeFollowupSignal, + dataScopeMetaQuery, + capabilityMetaQuery, + aggregateBusinessAnalyticsSignal, + dataRetrievalSignal, + strongDataSignal, + resolvedMode: resolvedModeDetection.mode, + resolvedIntent: resolvedIntentResolution.intent, + llmContractIntent, + llmContractMode + }); let runAddressLane = Boolean(baseToolGate?.runAddressLane); let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane"); let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0"); diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 9c4488d..845d459 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -22,6 +22,8 @@ import * as assistantCoverageGrounding_1 from "./assistantCoverageGrounding"; import * as assistantDeepTurnAttemptRuntimeAdapter_1 from "./assistantDeepTurnAttemptRuntimeAdapter"; import * as assistantBoundaryPolicy_1 from "./assistantBoundaryPolicy"; import * as assistantLivingModePolicy_1 from "./assistantLivingModePolicy"; +import * as assistantMetaFollowupPolicy_1 from "./assistantMetaFollowupPolicy"; +import * as assistantMemoryRecapPolicy_1 from "./assistantMemoryRecapPolicy"; import * as assistantRoutePolicy_1 from "./assistantRoutePolicy"; import * as assistantTransitionPolicy_1 from "./assistantTransitionPolicy"; import * as assistantOrganizationScopeRuntimeAdapter_1 from "./assistantOrganizationScopeRuntimeAdapter"; @@ -4714,6 +4716,16 @@ const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistan hasAssistantCapabilityQuestionSignal, hasOperationalAdminActionRequestSignal }); +const assistantMetaFollowupPolicy = (0, assistantMetaFollowupPolicy_1.createAssistantMetaFollowupPolicy)({ + hasAssistantDataScopeMetaQuestionSignal: assistantLivingModePolicy.hasAssistantDataScopeMetaQuestionSignal, + shouldHandleAsAssistantCapabilityMetaQuery: assistantLivingModePolicy.shouldHandleAsAssistantCapabilityMetaQuery, + hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal +}); +const assistantMemoryRecapPolicy = (0, assistantMemoryRecapPolicy_1.createAssistantMemoryRecapPolicy)({ + hasHistoricalCapabilityFollowupSignal: assistantLivingModePolicy.hasHistoricalCapabilityFollowupSignal, + hasConversationMemoryRecallFollowupSignal: assistantLivingModePolicy.hasConversationMemoryRecallFollowupSignal, + isGroundedInventoryContextDebug +}); const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePolicy)({ repairAddressMojibake, findLastGroundedAddressAnswerDebug, @@ -4721,8 +4733,9 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, - hasAssistantDataScopeMetaQuestionSignal: assistantLivingModePolicy.hasAssistantDataScopeMetaQuestionSignal, - shouldHandleAsAssistantCapabilityMetaQuery: assistantLivingModePolicy.shouldHandleAsAssistantCapabilityMetaQuery, + resolveMetaSignalSet: assistantMetaFollowupPolicy.resolveMetaSignalSet, + resolveHardMetaMode: assistantMetaFollowupPolicy.resolveHardMetaMode, + isMetaFollowupOverGroundedAnswer: assistantMetaFollowupPolicy.isMetaFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal: assistantLivingModePolicy.hasDataRetrievalRequestSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, @@ -4738,11 +4751,8 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, - hasHistoricalCapabilityFollowupSignal: assistantLivingModePolicy.hasHistoricalCapabilityFollowupSignal, - isGroundedInventoryContextDebug, - hasConversationMemoryRecallFollowupSignal: assistantLivingModePolicy.hasConversationMemoryRecallFollowupSignal, + resolveRouteMemorySignals: assistantMemoryRecapPolicy.resolveRouteMemorySignals, findLastAddressAssistantItem, - hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal, resolveAddressToolGateDecision, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts new file mode 100644 index 0000000..a25b9eb --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + buildAddressMemoryRecapReply, + createAssistantMemoryRecapPolicy, + resolveAssistantLivingChatMemoryContext +} from "../src/services/assistantMemoryRecapPolicy"; + +const policy = createAssistantMemoryRecapPolicy({ + hasHistoricalCapabilityFollowupSignal: (text: unknown) => + /историческ|архив|раньше/i.test(String(text ?? "")), + hasConversationMemoryRecallFollowupSignal: (text: unknown) => + /помнишь|remember/i.test(String(text ?? "")), + isGroundedInventoryContextDebug: (debug: unknown) => + String((debug as Record | null)?.detected_intent ?? "") === "inventory_on_hand_as_of_date" +}); + +describe("assistantMemoryRecapPolicy", () => { + it("detects contextual historical capability follow-up", () => { + const signals = policy.resolveRouteMemorySignals({ + rawUserMessage: "а исторические остатки тоже можешь?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "", + dataScopeMetaQuery: false, + capabilityMetaQuery: true, + dataRetrievalSignal: false, + strongDataSignal: false, + aggregateBusinessAnalyticsSignal: false, + lastGroundedAddressDebug: { + detected_intent: "inventory_on_hand_as_of_date" + }, + hasPriorAddressDebug: true + }); + + expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(true); + expect(signals.contextualMemoryRecapFollowupDetected).toBe(false); + }); + + it("detects contextual memory recap over prior address debug", () => { + const signals = policy.resolveRouteMemorySignals({ + rawUserMessage: "а ты помнишь что мы обсуждали?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "", + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + dataRetrievalSignal: false, + strongDataSignal: false, + aggregateBusinessAnalyticsSignal: false, + lastGroundedAddressDebug: null, + hasPriorAddressDebug: true + }); + + expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false); + expect(signals.contextualMemoryRecapFollowupDetected).toBe(true); + }); + + it("builds deterministic recap from prior selected object context", () => { + const context = resolveAssistantLivingChatMemoryContext({ + modeDecisionReason: "memory_recap_followup_detected", + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + anchor_type: "item", + anchor_value_resolved: "Рабочая станция", + extracted_filters: { + item: "Рабочая станция", + as_of_date: "2022-02-28" + } + } + } + ] + }); + + const reply = buildAddressMemoryRecapReply({ + organization: null, + addressDebug: context.lastMemoryAddressDebug, + toNonEmptyString: (value: unknown) => { + const text = String(value ?? "").trim(); + return text.length > 0 ? text : null; + } + }); + + expect(context.contextualMemoryRecapFollowup).toBe(true); + expect(reply).toContain("Рабочая станция"); + expect(reply).toContain("28.02.2022"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts b/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts new file mode 100644 index 0000000..7fc755c --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { createAssistantMetaFollowupPolicy } from "../src/services/assistantMetaFollowupPolicy"; + +const policy = createAssistantMetaFollowupPolicy({ + hasAssistantDataScopeMetaQuestionSignal: (text: unknown) => + /по какой компании|какая база/i.test(String(text ?? "")), + shouldHandleAsAssistantCapabilityMetaQuery: (text: unknown) => + /что ты можешь|что ты умеешь/i.test(String(text ?? "")), + hasMetaAnswerFollowupSignal: (text: unknown) => + /это норм|что думаешь/i.test(String(text ?? "")) +}); + +describe("assistantMetaFollowupPolicy", () => { + it("collects meta signals across message variants", () => { + const signals = policy.resolveMetaSignalSet({ + rawUserMessage: "", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "по какой компании мы можем работать?", + repairedEffectiveAddressUserMessage: "" + }); + + expect(signals.dataScopeMetaQuery).toBe(true); + expect(signals.capabilityMetaQuery).toBe(false); + expect(signals.metaAnswerFollowupSignal).toBe(false); + }); + + it("treats historical capability phrasing as capability meta follow-up", () => { + const signals = policy.resolveMetaSignalSet({ + rawUserMessage: "а исторические остатки тоже можешь?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "" + }); + + expect(signals.capabilityMetaQuery).toBe(true); + }); + + it("resolves hard meta mode with data retrieval guard", () => { + expect( + policy.resolveHardMetaMode({ + dataScopeMetaQuery: true, + capabilityMetaQuery: false, + dataRetrievalSignal: false + }) + ).toBe("data_scope"); + + expect( + policy.resolveHardMetaMode({ + dataScopeMetaQuery: false, + capabilityMetaQuery: true, + dataRetrievalSignal: true + }) + ).toBeNull(); + }); + + it("detects evaluative meta follow-up over grounded answer", () => { + const detected = policy.isMetaFollowupOverGroundedAnswer({ + followupContext: { previous_intent: "vat_payable_forecast" }, + hasPriorAddressAnswerContext: true, + metaAnswerFollowupSignal: true, + vatEvaluativeFollowupSignal: false, + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + aggregateBusinessAnalyticsSignal: false, + dataRetrievalSignal: false, + strongDataSignal: false, + resolvedMode: "unsupported", + resolvedIntent: "unknown", + llmContractIntent: "unknown", + llmContractMode: "unsupported" + }); + + expect(detected).toBe(true); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index edc23cf..77cd114 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -32,8 +32,35 @@ function buildPolicy(overrides: Record = {}) { ), normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage: () => null, - hasAssistantDataScopeMetaQuestionSignal: (text: string) => /по какой компании|какая база|по каким конторам/i.test(text), - shouldHandleAsAssistantCapabilityMetaQuery: (text: string) => /что ты можешь|что ты умеешь/i.test(text), + resolveMetaSignalSet: (input: { + rawUserMessage?: string; + repairedRawUserMessage?: string; + effectiveAddressUserMessage?: string; + repairedEffectiveAddressUserMessage?: string; + }) => { + const samples = [ + input.rawUserMessage, + input.repairedRawUserMessage, + input.effectiveAddressUserMessage, + input.repairedEffectiveAddressUserMessage + ].join(" "); + return { + dataScopeMetaQuery: /по какой компании|какая база|по каким конторам/i.test(samples), + capabilityMetaQuery: /что ты можешь|что ты умеешь/i.test(samples), + metaAnswerFollowupSignal: /это норм|что думаешь/i.test(samples) + }; + }, + resolveHardMetaMode: (input: { + dataScopeMetaQuery?: boolean; + capabilityMetaQuery?: boolean; + dataRetrievalSignal?: boolean; + }) => + input.dataScopeMetaQuery + ? "data_scope" + : input.capabilityMetaQuery && !input.dataRetrievalSignal + ? "capability" + : null, + isMetaFollowupOverGroundedAnswer: () => false, hasDataRetrievalRequestSignal: () => false, hasAggregateBusinessAnalyticsSignal: () => false, hasStandaloneAddressTopicSignal: () => false, @@ -49,11 +76,11 @@ function buildPolicy(overrides: Record = {}) { hasShortDebtMirrorFollowupSignal: () => false, isInventorySelectedObjectIntent: (intent: unknown) => /inventory/i.test(String(intent ?? "")), hasShortInventoryObjectFollowupSignal: () => false, - hasHistoricalCapabilityFollowupSignal: () => false, - isGroundedInventoryContextDebug: (debug: unknown) => Boolean(debug), - hasConversationMemoryRecallFollowupSignal: () => false, + resolveRouteMemorySignals: () => ({ + contextualHistoricalCapabilityFollowupDetected: false, + contextualMemoryRecapFollowupDetected: false + }), findLastAddressAssistantItem: () => null, - hasMetaAnswerFollowupSignal: () => false, resolveAddressToolGateDecision: () => ({ runAddressLane: false, decision: "skip_address_lane", @@ -118,7 +145,10 @@ describe("assistantRoutePolicy", () => { it("routes memory recap follow-up over grounded answer to chat", () => { const policy = buildPolicy({ - hasConversationMemoryRecallFollowupSignal: () => true, + resolveRouteMemorySignals: () => ({ + contextualHistoricalCapabilityFollowupDetected: false, + contextualMemoryRecapFollowupDetected: true + }), findLastGroundedAddressAnswerDebug: () => ({ execution_lane: "address_query" }) }); diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index bf5a448..bcae254 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -1,4 +1,38 @@ [ + { + "generation_id": "gen-ag04170830-5f771d", + "created_at": "2026-04-17T08:30:44+00:00", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 5 meta and memory recap replay over interrupted address context", + "count": 6, + "domain": "address_phase5_meta_memory_mix", + "questions": [ + "какие остатки на складе на март 2021", + "а исторические остатки тоже можешь?", + "по какой компании мы сейчас работаем?", + "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "что ты умеешь?", + "а ты помнишь, что мы по этой позиции уже выяснили?" + ], + "generated_by": "codex_agent", + "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260417083044_gen-ag04170830-5f771d.json", + "context": { + "llm_provider": null, + "model": null, + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "autogen_personality_id": null, + "autogen_personality_prompt": null, + "source_session_id": null, + "saved_session_file": "assistant_saved_session_20260417083044_gen-ag04170830-5f771d.json", + "saved_case_set_kind": "agent_semantic_scenario", + "agent_run": true, + "agent_focus": "meta and memory recap replay over interrupted address context", + "architecture_phase": "turnaround_11_phase5", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_meta_memory_mix.json" + } + }, { "generation_id": "gen-ag04170808-1907fa", "created_at": "2026-04-17T08:08:08+00:00", diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417083044_gen-ag04170830-5f771d.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417083044_gen-ag04170830-5f771d.json new file mode 100644 index 0000000..5585d0b --- /dev/null +++ b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260417083044_gen-ag04170830-5f771d.json @@ -0,0 +1,93 @@ +{ + "saved_at": "2026-04-17T08:30:44+00:00", + "generation_id": "gen-ag04170830-5f771d", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 5 meta and memory recap replay over interrupted address context", + "agent_run": true, + "questions": [ + "какие остатки на складе на март 2021", + "а исторические остатки тоже можешь?", + "по какой компании мы сейчас работаем?", + "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "что ты умеешь?", + "а ты помнишь, что мы по этой позиции уже выяснили?" + ], + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "meta and memory recap replay over interrupted address context", + "architecture_phase": "turnaround_11_phase5", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_meta_memory_mix.json" + }, + "source_session_id": null, + "session": { + "session_id": null, + "mode": "agent_semantic_run", + "items": [ + { + "message_id": "agent-user-001", + "role": "user", + "text": "какие остатки на складе на март 2021", + "created_at": "2026-04-17T08:30:44+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-002", + "role": "user", + "text": "а исторические остатки тоже можешь?", + "created_at": "2026-04-17T08:30:44+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-003", + "role": "user", + "text": "по какой компании мы сейчас работаем?", + "created_at": "2026-04-17T08:30:44+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-004", + "role": "user", + "text": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "created_at": "2026-04-17T08:30:44+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-005", + "role": "user", + "text": "что ты умеешь?", + "created_at": "2026-04-17T08:30:44+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-006", + "role": "user", + "text": "а ты помнишь, что мы по этой позиции уже выяснили?", + "created_at": "2026-04-17T08:30:44+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + } + ], + "agent_run": true, + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "meta and memory recap replay over interrupted address context", + "architecture_phase": "turnaround_11_phase5", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase5_meta_memory_mix.json" + } + } +} diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417083044_gen-ag04170830-5f771d.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417083044_gen-ag04170830-5f771d.json new file mode 100644 index 0000000..44e51d9 --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260417083044_gen-ag04170830-5f771d.json @@ -0,0 +1,43 @@ +{ + "suite_id": "assistant_saved_session_gen-ag04170830-5f771d", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_suite_v0_1", + "generated_at": "2026-04-17T08:30:44+00:00", + "generation_id": "gen-ag04170830-5f771d", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 5 meta and memory recap replay over interrupted address context", + "domain": "address_phase5_meta_memory_mix", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "agent_saved_user_sessions", + "title": "AGENT | Phase 5 meta and memory recap replay over interrupted address context", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "какие остатки на складе на март 2021" + }, + { + "user_message": "а исторические остатки тоже можешь?" + }, + { + "user_message": "по какой компании мы сейчас работаем?" + }, + { + "user_message": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?" + }, + { + "user_message": "что ты умеешь?" + }, + { + "user_message": "а ты помнишь, что мы по этой позиции уже выяснили?" + } + ] + } + ] +}