diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index 1566ef1..6a748ea 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -922,7 +922,31 @@ Validation: - `assistantMcpRuntimeBridge.test.ts` - `assistantAddressFollowupContext.test.ts` -Status: **In progress (Phase 2.1 + 2.2 + 2.3 + 2.4 + 2.5 + 2.6 + 2.7 + 2.8 + 2.9 + 2.10 + 2.11 + 2.12 + 2.13 + 2.14 + 2.15 + 2.16 + 2.17 + 2.18 + 2.19 + 2.20 + 2.21 + 2.22 + 2.23 + 2.24 + 2.25 + 2.26 completed)** +Implemented in current pass (Phase 2.27): +1. Extracted living-chat runtime orchestration branch from `assistantService` into dedicated runtime adapter: + - `assistantLivingChatRuntimeAdapter.ts` + - introduced: + - `runAssistantLivingChatRuntime(...)` +2. Centralized living-chat runtime branch sequence (behavior-preserving): + - deterministic capability + safety refusal branch; + - deterministic data-scope contract branch (with live probe projection); + - deterministic organization-boundary and scope-selection branches; + - deterministic operational-boundary and capability-contract branches; + - LLM chat branch with script-guard + grounding-guard post-processing. +3. Rewired `assistantService` `tryHandleLivingChat(...)` to consume runtime adapter output and keep existing chat finalization adapter path unchanged. +4. Added focused unit tests: + - `assistantLivingChatRuntimeAdapter.test.ts` + +Validation: +1. `npm run build` passed. +2. Targeted living/chat followup pack passed: + - `assistantLivingChatRuntimeAdapter.test.ts` + - `assistantLivingChatMode.test.ts` + - `assistantLivingRouter.test.ts` + - `assistantAddressFollowupContext.test.ts` + - `assistantWave10SettlementCorrectiveRegression.test.ts` + +Status: **In progress (Phase 2.1 + 2.2 + 2.3 + 2.4 + 2.5 + 2.6 + 2.7 + 2.8 + 2.9 + 2.10 + 2.11 + 2.12 + 2.13 + 2.14 + 2.15 + 2.16 + 2.17 + 2.18 + 2.19 + 2.20 + 2.21 + 2.22 + 2.23 + 2.24 + 2.25 + 2.26 + 2.27 completed)** ## Stage 3 (P2): Hybrid Semantic Layer (LLM + Deterministic Guards) diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js new file mode 100644 index 0000000..3b26f82 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -0,0 +1,144 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runAssistantLivingChatRuntime = runAssistantLivingChatRuntime; +async function runAssistantLivingChatRuntime(input) { + const userMessage = String(input.userMessage ?? ""); + const dataScopeMetaQuery = input.hasAssistantDataScopeMetaQuestionSignal(userMessage); + const capabilityMetaQuery = input.shouldHandleAsAssistantCapabilityMetaQuery(userMessage); + const destructiveSignal = input.hasDestructiveDataActionSignal(userMessage); + const dangerSignal = input.hasDangerOrCoercionSignal(userMessage); + const operationalSignal = input.hasOperationalAdminActionRequestSignal(userMessage); + let dataScopeProbe = null; + let chatText = ""; + let livingChatSource = "llm_chat"; + let livingChatScriptGuardApplied = false; + let livingChatScriptGuardReason = null; + let livingChatGroundingGuardApplied = false; + let livingChatGroundingGuardReason = null; + let knownOrganizations = input.mergeKnownOrganizations(input.sessionScope.knownOrganizations ?? []); + let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization); + let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization); + if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { + chatText = input.buildAssistantSafetyRefusalReply(); + livingChatSource = "deterministic_safety_refusal"; + } + else if (dataScopeMetaQuery) { + dataScopeProbe = await input.resolveDataScopeProbe(); + chatText = input.buildAssistantDataScopeContractReply(dataScopeProbe); + knownOrganizations = input.mergeKnownOrganizations([ + ...knownOrganizations, + ...(Array.isArray(dataScopeProbe?.organizations) ? dataScopeProbe.organizations : []) + ]); + if (!activeOrganization && knownOrganizations.length === 1) { + activeOrganization = knownOrganizations[0]; + } + livingChatSource = + dataScopeProbe?.status === "resolved" + ? "deterministic_data_scope_contract_live" + : "deterministic_data_scope_contract"; + } + else if ((selectedOrganization || activeOrganization) && input.hasOrganizationFactLookupSignal(userMessage)) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = input.buildAssistantOrganizationFactBoundaryReply(scopedOrganization); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_organization_fact_boundary"; + } + else if ((selectedOrganization || activeOrganization) && + input.hasOrganizationFactFollowupSignal(userMessage, input.sessionItems)) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = input.buildAssistantOrganizationFactBoundaryReply(scopedOrganization); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_organization_fact_boundary_followup"; + } + else if (!capabilityMetaQuery && + input.shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization ?? activeOrganization)) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = input.buildAssistantDataScopeSelectionReply(scopedOrganization); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_data_scope_selection_contract"; + } + else if (capabilityMetaQuery && operationalSignal && !input.hasAssistantCapabilityQuestionSignal(userMessage)) { + chatText = input.buildAssistantOperationalBoundaryReply(); + livingChatSource = "deterministic_operational_boundary"; + } + else if (capabilityMetaQuery) { + chatText = input.buildAssistantCapabilityContractReply(); + livingChatSource = "deterministic_capability_contract"; + } + else { + chatText = await input.executeLlmChat(); + const scriptGuard = input.applyScriptGuard(chatText, userMessage); + chatText = scriptGuard.text; + if (scriptGuard.applied) { + livingChatScriptGuardApplied = true; + livingChatScriptGuardReason = scriptGuard.reason; + livingChatSource = "llm_chat_script_guard"; + } + const groundingGuard = input.applyGroundingGuard({ + userMessage, + chatText, + organization: activeOrganization ?? selectedOrganization ?? null + }); + chatText = groundingGuard.text; + if (groundingGuard.applied) { + livingChatGroundingGuardApplied = true; + livingChatGroundingGuardReason = groundingGuard.reason; + livingChatSource = "llm_chat_grounding_guard"; + } + } + if (!chatText) { + return { + handled: false, + chatText: "", + debug: null + }; + } + const addressRuntimeMeta = (input.addressRuntimeMeta && typeof input.addressRuntimeMeta === "object" + ? input.addressRuntimeMeta + : {}); + const predecomposeContract = addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object" + ? addressRuntimeMeta.predecomposeContract + : null; + const debug = { + trace_id: input.traceIdFactory(), + prompt_version: "living_chat_router_v1", + schema_version: "living_chat_router_v1", + fallback_type: "none", + detected_mode: "chat", + detected_mode_confidence: "high", + execution_lane: "living_chat", + living_router_mode: input.modeDecision?.mode ?? "chat", + living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected", + living_chat_response_source: livingChatSource, + living_chat_script_guard_applied: livingChatScriptGuardApplied, + living_chat_script_guard_reason: livingChatScriptGuardReason, + living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, + living_chat_grounding_guard_reason: livingChatGroundingGuardReason, + living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null, + living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null, + living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations) + ? dataScopeProbe.organizations.length + : 0, + living_chat_data_scope_probe_organizations: Array.isArray(dataScopeProbe?.organizations) + ? input.mergeKnownOrganizations(dataScopeProbe.organizations) + : [], + living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null, + living_chat_selected_organization: selectedOrganization ?? null, + assistant_known_organizations: knownOrganizations, + assistant_active_organization: activeOrganization ?? null, + address_llm_predecompose_attempted: Boolean(addressRuntimeMeta.attempted), + address_llm_predecompose_applied: Boolean(addressRuntimeMeta.applied), + address_llm_predecompose_reason: addressRuntimeMeta.reason ?? null, + address_llm_predecompose_contract: predecomposeContract, + orchestration_contract_v1: addressRuntimeMeta.orchestrationContract ?? null, + tool_gate_decision: addressRuntimeMeta.toolGateDecision ?? null, + tool_gate_reason: addressRuntimeMeta.toolGateReason ?? null, + normalized: null, + normalizer_output: null + }; + return { + handled: true, + chatText, + debug + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index aad0f9f..0749f3b 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -76,6 +76,7 @@ const assistantDeepTurnPackagingRuntimeAdapter_1 = __importStar(require("./assis const assistantDeepTurnPlanRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnPlanRuntimeAdapter")); const assistantDeepTurnRetrievalRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnRetrievalRuntimeAdapter")); const assistantLivingChatTurnFinalizeRuntimeAdapter_1 = __importStar(require("./assistantLivingChatTurnFinalizeRuntimeAdapter")); +const assistantLivingChatRuntimeAdapter_1 = __importStar(require("./assistantLivingChatRuntimeAdapter")); const assistantQueryPlanning_1 = __importStar(require("./assistantQueryPlanning")); const iconv_lite_1 = __importDefault(require("iconv-lite")); const DATA_SCOPE_CACHE_TTL_MS = 60_000; @@ -4461,155 +4462,75 @@ class AssistantService { }; const tryHandleLivingChat = async (modeDecision, addressRuntimeMeta = null) => { try { - const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(userMessage); - const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(userMessage); - const destructiveSignal = hasDestructiveDataActionSignal(userMessage); - const dangerSignal = hasDangerOrCoercionSignal(userMessage); - const operationalSignal = hasOperationalAdminActionRequestSignal(userMessage); - let dataScopeProbe = null; - let chatText = ""; - let livingChatSource = "llm_chat"; - let livingChatScriptGuardApplied = false; - let livingChatScriptGuardReason = null; - let livingChatGroundingGuardApplied = false; - let livingChatGroundingGuardReason = null; - let knownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations); - let selectedOrganization = toNonEmptyString(sessionOrganizationScope.selectedOrganization); - let activeOrganization = toNonEmptyString(sessionOrganizationScope.activeOrganization); - if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { - chatText = buildAssistantSafetyRefusalReply(); - livingChatSource = "deterministic_safety_refusal"; - } - else if (dataScopeMetaQuery) { - dataScopeProbe = await resolveAssistantDataScopeProbe(); - chatText = buildAssistantDataScopeContractReply(dataScopeProbe); - knownOrganizations = mergeKnownOrganizations([ - ...knownOrganizations, - ...(Array.isArray(dataScopeProbe?.organizations) ? dataScopeProbe.organizations : []) - ]); - if (!activeOrganization && knownOrganizations.length === 1) { - activeOrganization = knownOrganizations[0]; - } - livingChatSource = dataScopeProbe?.status === "resolved" - ? "deterministic_data_scope_contract_live" - : "deterministic_data_scope_contract"; - } - else if ((selectedOrganization || activeOrganization) && hasOrganizationFactLookupSignal(userMessage)) { - const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization); - activeOrganization = scopedOrganization ?? activeOrganization; - livingChatSource = "deterministic_organization_fact_boundary"; - } - else if ((selectedOrganization || activeOrganization) && hasOrganizationFactFollowupSignal(userMessage, session.items)) { - const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization); - activeOrganization = scopedOrganization ?? activeOrganization; - livingChatSource = "deterministic_organization_fact_boundary_followup"; - } - else if (!capabilityMetaQuery && shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization ?? activeOrganization)) { - const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAssistantDataScopeSelectionReply(scopedOrganization); - activeOrganization = scopedOrganization ?? activeOrganization; - livingChatSource = "deterministic_data_scope_selection_contract"; - } - else if (capabilityMetaQuery && operationalSignal && !hasAssistantCapabilityQuestionSignal(userMessage)) { - chatText = buildAssistantOperationalBoundaryReply(); - livingChatSource = "deterministic_operational_boundary"; - } - else if (capabilityMetaQuery) { - chatText = buildAssistantCapabilityContractReply(); - livingChatSource = "deterministic_capability_contract"; - } - else { - const conversationWindow = buildLivingChatContextWindow(session.items); - const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow); - const canonExcerpt = (0, assistantCanon_1.loadAssistantCanonExcerpt)(520); - const chatResponse = await this.chatClient.chat({ - llmProvider: payload.llmProvider, - apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""), - model: String(payload.model ?? config_1.DEFAULT_MODEL), - baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL, - temperature: payload.temperature ?? 0.35, - maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)) - }, { - systemPrompt: [ - "Ты живой русскоязычный ассистент для чтения и анализа данных 1С.", - "Работай честно: не заявляй действия, которые недоступны в этом рантайме.", - "Разрешено: анализ и объяснение данных, формулировка запросов, подсказки по следующему шагу.", - "Запрещено: обещать настройку 1С, админ-действия, создание/проведение документов или любые изменения в базе.", - "Если пользователь спрашивает про возможности, отвечай только по этому контракту.", - `Канон поведения: ${canonExcerpt}` - ].join(" "), - developerPrompt: "Формат: коротко и по сути, без JSON и без служебных блоков. Пиши человеко-понятно.", - userMessage: userPrompt, - maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)), - temperature: payload.temperature ?? 0.35 - }); - chatText = sanitizeOutgoingAssistantText(chatResponse?.outputText ?? "", "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам."); - const scriptGuard = applyLivingChatScriptGuard(chatText, userMessage); - chatText = scriptGuard.text; - if (scriptGuard.applied) { - livingChatScriptGuardApplied = true; - livingChatScriptGuardReason = scriptGuard.reason; - livingChatSource = "llm_chat_script_guard"; - } - const groundingGuard = applyLivingChatGroundingGuard({ - userMessage, - chatText, - organization: activeOrganization ?? selectedOrganization ?? null - }); - chatText = groundingGuard.text; - if (groundingGuard.applied) { - livingChatGroundingGuardApplied = true; - livingChatGroundingGuardReason = groundingGuard.reason; - livingChatSource = "llm_chat_grounding_guard"; - } - } - if (!chatText) { + const runtime = await (0, assistantLivingChatRuntimeAdapter_1.runAssistantLivingChatRuntime)({ + userMessage, + sessionItems: session.items, + modeDecision, + sessionScope: { + knownOrganizations: sessionOrganizationScope.knownOrganizations, + selectedOrganization: sessionOrganizationScope.selectedOrganization, + activeOrganization: sessionOrganizationScope.activeOrganization + }, + addressRuntimeMeta, + traceIdFactory: () => `chat-${(0, nanoid_1.nanoid)(10)}`, + toNonEmptyString, + mergeKnownOrganizations, + hasAssistantDataScopeMetaQuestionSignal, + shouldHandleAsAssistantCapabilityMetaQuery, + hasDestructiveDataActionSignal, + hasDangerOrCoercionSignal, + hasOperationalAdminActionRequestSignal, + hasOrganizationFactLookupSignal, + hasOrganizationFactFollowupSignal, + shouldEmitOrganizationSelectionReply, + hasAssistantCapabilityQuestionSignal, + resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(), + executeLlmChat: async () => { + const conversationWindow = buildLivingChatContextWindow(session.items); + const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow); + const canonExcerpt = (0, assistantCanon_1.loadAssistantCanonExcerpt)(520); + const chatResponse = await this.chatClient.chat({ + llmProvider: payload.llmProvider, + apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ''), + model: String(payload.model ?? config_1.DEFAULT_MODEL), + baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL, + temperature: payload.temperature ?? 0.35, + maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)) + }, { + systemPrompt: [ + 'Ты живой русскоязычный ассистент для чтения и анализа данных 1С.', + 'Работай честно: не заявляй действия, которые недоступны в этом рантайме.', + 'Разрешено: анализ и объяснение данных, формулировка запросов, подсказки по следующему шагу.', + 'Запрещено: обещать настройку 1С, админ-действия, создание/проведение документов или любые изменения в базе.', + 'Если пользователь спрашивает про возможности, отвечай только по этому контракту.', + `Канон поведения: ${canonExcerpt}` + ].join(' '), + developerPrompt: 'Формат: коротко и по сути, без JSON и без служебных блоков. Пиши человеко-понятно.', + userMessage: userPrompt, + maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)), + temperature: payload.temperature ?? 0.35 + }); + return sanitizeOutgoingAssistantText(chatResponse?.outputText ?? '', 'Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.'); + }, + applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage), + applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput), + buildAssistantSafetyRefusalReply, + buildAssistantDataScopeContractReply, + buildAssistantOrganizationFactBoundaryReply, + buildAssistantDataScopeSelectionReply, + buildAssistantOperationalBoundaryReply, + buildAssistantCapabilityContractReply + }); + if (!runtime.handled || !runtime.debug) { return null; } - const debug = { - trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`, - prompt_version: "living_chat_router_v1", - schema_version: "living_chat_router_v1", - fallback_type: "none", - detected_mode: "chat", - detected_mode_confidence: "high", - execution_lane: "living_chat", - living_router_mode: modeDecision?.mode ?? "chat", - living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected", - living_chat_response_source: livingChatSource, - living_chat_script_guard_applied: livingChatScriptGuardApplied, - living_chat_script_guard_reason: livingChatScriptGuardReason, - living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, - living_chat_grounding_guard_reason: livingChatGroundingGuardReason, - living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null, - living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null, - living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations) - ? dataScopeProbe.organizations.length - : 0, - living_chat_data_scope_probe_organizations: Array.isArray(dataScopeProbe?.organizations) - ? mergeKnownOrganizations(dataScopeProbe.organizations) - : [], - living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null, - living_chat_selected_organization: selectedOrganization ?? null, - assistant_known_organizations: knownOrganizations, - assistant_active_organization: activeOrganization ?? null, - address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted), - address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied), - address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null, - address_llm_predecompose_contract: addressRuntimeMeta?.predecomposeContract ?? null, - orchestration_contract_v1: addressRuntimeMeta?.orchestrationContract ?? null, - tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null, - tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null, - normalized: null, - normalizer_output: null - }; + const chatText = runtime.chatText; + const debug = runtime.debug; const finalization = (0, assistantLivingChatTurnFinalizeRuntimeAdapter_1.finalizeAssistantLivingChatTurn)({ sessionId, userMessage, assistantReply: chatText, - replyType: "factual_with_explanation", + replyType: 'factual_with_explanation', debug, modeDecision, appendItem: (targetSessionId, item) => this.sessions.appendItem(targetSessionId, item), @@ -4624,9 +4545,9 @@ class AssistantService { catch (error) { (0, log_1.logJson)({ timestamp: new Date().toISOString(), - level: "warn", - service: "assistant_loop", - message: "assistant_living_chat_failed_fallback_to_deep", + level: 'warn', + service: 'assistant_loop', + message: 'assistant_living_chat_failed_fallback_to_deep', sessionId, details: { session_id: sessionId, diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts new file mode 100644 index 0000000..d85032e --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -0,0 +1,206 @@ +export interface AssistantLivingChatSessionScopeInput { + knownOrganizations?: unknown[]; + selectedOrganization?: unknown; + activeOrganization?: unknown; +} + +export interface AssistantLivingChatModeDecisionInput { + mode?: string | null; + reason?: string | null; +} + +export interface AssistantLivingChatRuntimeInput { + userMessage: string; + sessionItems: unknown[]; + modeDecision?: AssistantLivingChatModeDecisionInput | null; + sessionScope: AssistantLivingChatSessionScopeInput; + addressRuntimeMeta?: Record | null; + traceIdFactory: () => string; + toNonEmptyString: (value: unknown) => string | null; + mergeKnownOrganizations: (values: unknown[]) => string[]; + hasAssistantDataScopeMetaQuestionSignal: (message: string) => boolean; + shouldHandleAsAssistantCapabilityMetaQuery: (message: string) => boolean; + hasDestructiveDataActionSignal: (message: string) => boolean; + hasDangerOrCoercionSignal: (message: string) => boolean; + hasOperationalAdminActionRequestSignal: (message: string) => boolean; + hasOrganizationFactLookupSignal: (message: string) => boolean; + hasOrganizationFactFollowupSignal: (message: string, items: unknown[]) => boolean; + shouldEmitOrganizationSelectionReply: (message: string, activeOrganization: string | null) => boolean; + hasAssistantCapabilityQuestionSignal: (message: string) => boolean; + resolveDataScopeProbe: () => Promise | null>; + executeLlmChat: () => Promise; + applyScriptGuard: (chatText: string, userMessage: string) => { + text: string; + applied: boolean; + reason: string | null; + }; + applyGroundingGuard: (input: { + userMessage: string; + chatText: string; + organization: string | null; + }) => { + text: string; + applied: boolean; + reason: string | null; + }; + buildAssistantSafetyRefusalReply: () => string; + buildAssistantDataScopeContractReply: (scopeProbe: Record | null) => string; + buildAssistantOrganizationFactBoundaryReply: (organization: string | null) => string; + buildAssistantDataScopeSelectionReply: (organization: string | null) => string; + buildAssistantOperationalBoundaryReply: () => string; + buildAssistantCapabilityContractReply: () => string; +} + +export interface AssistantLivingChatRuntimeOutput { + handled: boolean; + chatText: string; + debug: Record | null; +} + +export async function runAssistantLivingChatRuntime( + input: AssistantLivingChatRuntimeInput +): Promise { + const userMessage = String(input.userMessage ?? ""); + const dataScopeMetaQuery = input.hasAssistantDataScopeMetaQuestionSignal(userMessage); + const capabilityMetaQuery = input.shouldHandleAsAssistantCapabilityMetaQuery(userMessage); + const destructiveSignal = input.hasDestructiveDataActionSignal(userMessage); + const dangerSignal = input.hasDangerOrCoercionSignal(userMessage); + const operationalSignal = input.hasOperationalAdminActionRequestSignal(userMessage); + + let dataScopeProbe: Record | null = null; + let chatText = ""; + let livingChatSource = "llm_chat"; + let livingChatScriptGuardApplied = false; + let livingChatScriptGuardReason: string | null = null; + let livingChatGroundingGuardApplied = false; + let livingChatGroundingGuardReason: string | null = null; + let knownOrganizations = input.mergeKnownOrganizations(input.sessionScope.knownOrganizations ?? []); + let selectedOrganization = input.toNonEmptyString(input.sessionScope.selectedOrganization); + let activeOrganization = input.toNonEmptyString(input.sessionScope.activeOrganization); + + if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { + chatText = input.buildAssistantSafetyRefusalReply(); + livingChatSource = "deterministic_safety_refusal"; + } else if (dataScopeMetaQuery) { + dataScopeProbe = await input.resolveDataScopeProbe(); + chatText = input.buildAssistantDataScopeContractReply(dataScopeProbe); + knownOrganizations = input.mergeKnownOrganizations([ + ...knownOrganizations, + ...(Array.isArray(dataScopeProbe?.organizations) ? (dataScopeProbe.organizations as unknown[]) : []) + ]); + if (!activeOrganization && knownOrganizations.length === 1) { + activeOrganization = knownOrganizations[0]; + } + livingChatSource = + dataScopeProbe?.status === "resolved" + ? "deterministic_data_scope_contract_live" + : "deterministic_data_scope_contract"; + } else if ((selectedOrganization || activeOrganization) && input.hasOrganizationFactLookupSignal(userMessage)) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = input.buildAssistantOrganizationFactBoundaryReply(scopedOrganization); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_organization_fact_boundary"; + } else if ( + (selectedOrganization || activeOrganization) && + input.hasOrganizationFactFollowupSignal(userMessage, input.sessionItems) + ) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = input.buildAssistantOrganizationFactBoundaryReply(scopedOrganization); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_organization_fact_boundary_followup"; + } else if ( + !capabilityMetaQuery && + input.shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization ?? activeOrganization) + ) { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; + chatText = input.buildAssistantDataScopeSelectionReply(scopedOrganization); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_data_scope_selection_contract"; + } else if (capabilityMetaQuery && operationalSignal && !input.hasAssistantCapabilityQuestionSignal(userMessage)) { + chatText = input.buildAssistantOperationalBoundaryReply(); + livingChatSource = "deterministic_operational_boundary"; + } else if (capabilityMetaQuery) { + chatText = input.buildAssistantCapabilityContractReply(); + livingChatSource = "deterministic_capability_contract"; + } else { + chatText = await input.executeLlmChat(); + const scriptGuard = input.applyScriptGuard(chatText, userMessage); + chatText = scriptGuard.text; + if (scriptGuard.applied) { + livingChatScriptGuardApplied = true; + livingChatScriptGuardReason = scriptGuard.reason; + livingChatSource = "llm_chat_script_guard"; + } + const groundingGuard = input.applyGroundingGuard({ + userMessage, + chatText, + organization: activeOrganization ?? selectedOrganization ?? null + }); + chatText = groundingGuard.text; + if (groundingGuard.applied) { + livingChatGroundingGuardApplied = true; + livingChatGroundingGuardReason = groundingGuard.reason; + livingChatSource = "llm_chat_grounding_guard"; + } + } + + if (!chatText) { + return { + handled: false, + chatText: "", + debug: null + }; + } + + const addressRuntimeMeta = (input.addressRuntimeMeta && typeof input.addressRuntimeMeta === "object" + ? input.addressRuntimeMeta + : {}) as Record; + const predecomposeContract = + addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object" + ? (addressRuntimeMeta.predecomposeContract as Record) + : null; + + const debug: Record = { + trace_id: input.traceIdFactory(), + prompt_version: "living_chat_router_v1", + schema_version: "living_chat_router_v1", + fallback_type: "none", + detected_mode: "chat", + detected_mode_confidence: "high", + execution_lane: "living_chat", + living_router_mode: input.modeDecision?.mode ?? "chat", + living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected", + living_chat_response_source: livingChatSource, + living_chat_script_guard_applied: livingChatScriptGuardApplied, + living_chat_script_guard_reason: livingChatScriptGuardReason, + living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, + living_chat_grounding_guard_reason: livingChatGroundingGuardReason, + living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null, + living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null, + living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations) + ? dataScopeProbe.organizations.length + : 0, + living_chat_data_scope_probe_organizations: Array.isArray(dataScopeProbe?.organizations) + ? input.mergeKnownOrganizations(dataScopeProbe.organizations as unknown[]) + : [], + living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null, + living_chat_selected_organization: selectedOrganization ?? null, + assistant_known_organizations: knownOrganizations, + assistant_active_organization: activeOrganization ?? null, + address_llm_predecompose_attempted: Boolean(addressRuntimeMeta.attempted), + address_llm_predecompose_applied: Boolean(addressRuntimeMeta.applied), + address_llm_predecompose_reason: addressRuntimeMeta.reason ?? null, + address_llm_predecompose_contract: predecomposeContract, + orchestration_contract_v1: addressRuntimeMeta.orchestrationContract ?? null, + tool_gate_decision: addressRuntimeMeta.toolGateDecision ?? null, + tool_gate_reason: addressRuntimeMeta.toolGateReason ?? null, + normalized: null, + normalizer_output: null + }; + + return { + handled: true, + chatText, + debug + }; +} diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index a24712f..b4b78a6 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -30,6 +30,7 @@ import * as assistantDeepTurnPackagingRuntimeAdapter_1 from "./assistantDeepTurn import * as assistantDeepTurnPlanRuntimeAdapter_1 from "./assistantDeepTurnPlanRuntimeAdapter"; import * as assistantDeepTurnRetrievalRuntimeAdapter_1 from "./assistantDeepTurnRetrievalRuntimeAdapter"; import * as assistantLivingChatTurnFinalizeRuntimeAdapter_1 from "./assistantLivingChatTurnFinalizeRuntimeAdapter"; +import * as assistantLivingChatRuntimeAdapter_1 from "./assistantLivingChatRuntimeAdapter"; import * as assistantQueryPlanning_1 from "./assistantQueryPlanning"; import iconv from "iconv-lite"; const DATA_SCOPE_CACHE_TTL_MS = 60_000; @@ -4416,155 +4417,75 @@ export class AssistantService { }; const tryHandleLivingChat = async (modeDecision, addressRuntimeMeta = null) => { try { - const dataScopeMetaQuery = hasAssistantDataScopeMetaQuestionSignal(userMessage); - const capabilityMetaQuery = shouldHandleAsAssistantCapabilityMetaQuery(userMessage); - const destructiveSignal = hasDestructiveDataActionSignal(userMessage); - const dangerSignal = hasDangerOrCoercionSignal(userMessage); - const operationalSignal = hasOperationalAdminActionRequestSignal(userMessage); - let dataScopeProbe = null; - let chatText = ""; - let livingChatSource = "llm_chat"; - let livingChatScriptGuardApplied = false; - let livingChatScriptGuardReason = null; - let livingChatGroundingGuardApplied = false; - let livingChatGroundingGuardReason = null; - let knownOrganizations = mergeKnownOrganizations(sessionOrganizationScope.knownOrganizations); - let selectedOrganization = toNonEmptyString(sessionOrganizationScope.selectedOrganization); - let activeOrganization = toNonEmptyString(sessionOrganizationScope.activeOrganization); - if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { - chatText = buildAssistantSafetyRefusalReply(); - livingChatSource = "deterministic_safety_refusal"; - } - else if (dataScopeMetaQuery) { - dataScopeProbe = await resolveAssistantDataScopeProbe(); - chatText = buildAssistantDataScopeContractReply(dataScopeProbe); - knownOrganizations = mergeKnownOrganizations([ - ...knownOrganizations, - ...(Array.isArray(dataScopeProbe?.organizations) ? dataScopeProbe.organizations : []) - ]); - if (!activeOrganization && knownOrganizations.length === 1) { - activeOrganization = knownOrganizations[0]; - } - livingChatSource = dataScopeProbe?.status === "resolved" - ? "deterministic_data_scope_contract_live" - : "deterministic_data_scope_contract"; - } - else if ((selectedOrganization || activeOrganization) && hasOrganizationFactLookupSignal(userMessage)) { - const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization); - activeOrganization = scopedOrganization ?? activeOrganization; - livingChatSource = "deterministic_organization_fact_boundary"; - } - else if ((selectedOrganization || activeOrganization) && hasOrganizationFactFollowupSignal(userMessage, session.items)) { - const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAssistantOrganizationFactBoundaryReply(scopedOrganization); - activeOrganization = scopedOrganization ?? activeOrganization; - livingChatSource = "deterministic_organization_fact_boundary_followup"; - } - else if (!capabilityMetaQuery && shouldEmitOrganizationSelectionReply(userMessage, selectedOrganization ?? activeOrganization)) { - const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; - chatText = buildAssistantDataScopeSelectionReply(scopedOrganization); - activeOrganization = scopedOrganization ?? activeOrganization; - livingChatSource = "deterministic_data_scope_selection_contract"; - } - else if (capabilityMetaQuery && operationalSignal && !hasAssistantCapabilityQuestionSignal(userMessage)) { - chatText = buildAssistantOperationalBoundaryReply(); - livingChatSource = "deterministic_operational_boundary"; - } - else if (capabilityMetaQuery) { - chatText = buildAssistantCapabilityContractReply(); - livingChatSource = "deterministic_capability_contract"; - } - else { - const conversationWindow = buildLivingChatContextWindow(session.items); - const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow); - const canonExcerpt = (0, assistantCanon_1.loadAssistantCanonExcerpt)(520); - const chatResponse = await this.chatClient.chat({ - llmProvider: payload.llmProvider, - apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ""), - model: String(payload.model ?? config_1.DEFAULT_MODEL), - baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL, - temperature: payload.temperature ?? 0.35, - maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)) - }, { - systemPrompt: [ - "Ты живой русскоязычный ассистент для чтения и анализа данных 1С.", - "Работай честно: не заявляй действия, которые недоступны в этом рантайме.", - "Разрешено: анализ и объяснение данных, формулировка запросов, подсказки по следующему шагу.", - "Запрещено: обещать настройку 1С, админ-действия, создание/проведение документов или любые изменения в базе.", - "Если пользователь спрашивает про возможности, отвечай только по этому контракту.", - `Канон поведения: ${canonExcerpt}` - ].join(" "), - developerPrompt: "Формат: коротко и по сути, без JSON и без служебных блоков. Пиши человеко-понятно.", - userMessage: userPrompt, - maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)), - temperature: payload.temperature ?? 0.35 - }); - chatText = sanitizeOutgoingAssistantText(chatResponse?.outputText ?? "", "Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам."); - const scriptGuard = applyLivingChatScriptGuard(chatText, userMessage); - chatText = scriptGuard.text; - if (scriptGuard.applied) { - livingChatScriptGuardApplied = true; - livingChatScriptGuardReason = scriptGuard.reason; - livingChatSource = "llm_chat_script_guard"; - } - const groundingGuard = applyLivingChatGroundingGuard({ - userMessage, - chatText, - organization: activeOrganization ?? selectedOrganization ?? null - }); - chatText = groundingGuard.text; - if (groundingGuard.applied) { - livingChatGroundingGuardApplied = true; - livingChatGroundingGuardReason = groundingGuard.reason; - livingChatSource = "llm_chat_grounding_guard"; - } - } - if (!chatText) { + const runtime = await (0, assistantLivingChatRuntimeAdapter_1.runAssistantLivingChatRuntime)({ + userMessage, + sessionItems: session.items, + modeDecision, + sessionScope: { + knownOrganizations: sessionOrganizationScope.knownOrganizations, + selectedOrganization: sessionOrganizationScope.selectedOrganization, + activeOrganization: sessionOrganizationScope.activeOrganization + }, + addressRuntimeMeta, + traceIdFactory: () => `chat-${(0, nanoid_1.nanoid)(10)}`, + toNonEmptyString, + mergeKnownOrganizations, + hasAssistantDataScopeMetaQuestionSignal, + shouldHandleAsAssistantCapabilityMetaQuery, + hasDestructiveDataActionSignal, + hasDangerOrCoercionSignal, + hasOperationalAdminActionRequestSignal, + hasOrganizationFactLookupSignal, + hasOrganizationFactFollowupSignal, + shouldEmitOrganizationSelectionReply, + hasAssistantCapabilityQuestionSignal, + resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(), + executeLlmChat: async () => { + const conversationWindow = buildLivingChatContextWindow(session.items); + const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow); + const canonExcerpt = (0, assistantCanon_1.loadAssistantCanonExcerpt)(520); + const chatResponse = await this.chatClient.chat({ + llmProvider: payload.llmProvider, + apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ''), + model: String(payload.model ?? config_1.DEFAULT_MODEL), + baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL, + temperature: payload.temperature ?? 0.35, + maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)) + }, { + systemPrompt: [ + 'Ты живой русскоязычный ассистент для чтения и анализа данных 1С.', + 'Работай честно: не заявляй действия, которые недоступны в этом рантайме.', + 'Разрешено: анализ и объяснение данных, формулировка запросов, подсказки по следующему шагу.', + 'Запрещено: обещать настройку 1С, админ-действия, создание/проведение документов или любые изменения в базе.', + 'Если пользователь спрашивает про возможности, отвечай только по этому контракту.', + `Канон поведения: ${canonExcerpt}` + ].join(' '), + developerPrompt: 'Формат: коротко и по сути, без JSON и без служебных блоков. Пиши человеко-понятно.', + userMessage: userPrompt, + maxOutputTokens: Math.max(120, Math.min(Number(payload.maxOutputTokens ?? 420), 900)), + temperature: payload.temperature ?? 0.35 + }); + return sanitizeOutgoingAssistantText(chatResponse?.outputText ?? '', 'Понял. Сформулируйте, что именно нужно по данным 1С, и я помогу по шагам.'); + }, + applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage), + applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput), + buildAssistantSafetyRefusalReply, + buildAssistantDataScopeContractReply, + buildAssistantOrganizationFactBoundaryReply, + buildAssistantDataScopeSelectionReply, + buildAssistantOperationalBoundaryReply, + buildAssistantCapabilityContractReply + }); + if (!runtime.handled || !runtime.debug) { return null; } - const debug = { - trace_id: `chat-${(0, nanoid_1.nanoid)(10)}`, - prompt_version: "living_chat_router_v1", - schema_version: "living_chat_router_v1", - fallback_type: "none", - detected_mode: "chat", - detected_mode_confidence: "high", - execution_lane: "living_chat", - living_router_mode: modeDecision?.mode ?? "chat", - living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected", - living_chat_response_source: livingChatSource, - living_chat_script_guard_applied: livingChatScriptGuardApplied, - living_chat_script_guard_reason: livingChatScriptGuardReason, - living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, - living_chat_grounding_guard_reason: livingChatGroundingGuardReason, - living_chat_data_scope_probe_status: dataScopeProbe?.status ?? null, - living_chat_data_scope_probe_channel: dataScopeProbe?.channel ?? null, - living_chat_data_scope_probe_org_count: Array.isArray(dataScopeProbe?.organizations) - ? dataScopeProbe.organizations.length - : 0, - living_chat_data_scope_probe_organizations: Array.isArray(dataScopeProbe?.organizations) - ? mergeKnownOrganizations(dataScopeProbe.organizations) - : [], - living_chat_data_scope_probe_error: dataScopeProbe?.error ?? null, - living_chat_selected_organization: selectedOrganization ?? null, - assistant_known_organizations: knownOrganizations, - assistant_active_organization: activeOrganization ?? null, - address_llm_predecompose_attempted: Boolean(addressRuntimeMeta?.attempted), - address_llm_predecompose_applied: Boolean(addressRuntimeMeta?.applied), - address_llm_predecompose_reason: addressRuntimeMeta?.reason ?? null, - address_llm_predecompose_contract: addressRuntimeMeta?.predecomposeContract ?? null, - orchestration_contract_v1: addressRuntimeMeta?.orchestrationContract ?? null, - tool_gate_decision: addressRuntimeMeta?.toolGateDecision ?? null, - tool_gate_reason: addressRuntimeMeta?.toolGateReason ?? null, - normalized: null, - normalizer_output: null - }; + const chatText = runtime.chatText; + const debug = runtime.debug; const finalization = (0, assistantLivingChatTurnFinalizeRuntimeAdapter_1.finalizeAssistantLivingChatTurn)({ sessionId, userMessage, assistantReply: chatText, - replyType: "factual_with_explanation", + replyType: 'factual_with_explanation', debug, modeDecision, appendItem: (targetSessionId, item) => this.sessions.appendItem(targetSessionId, item), @@ -4579,9 +4500,9 @@ export class AssistantService { catch (error) { (0, log_1.logJson)({ timestamp: new Date().toISOString(), - level: "warn", - service: "assistant_loop", - message: "assistant_living_chat_failed_fallback_to_deep", + level: 'warn', + service: 'assistant_loop', + message: 'assistant_living_chat_failed_fallback_to_deep', sessionId, details: { session_id: sessionId, diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts new file mode 100644 index 0000000..b4588d0 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; +import { runAssistantLivingChatRuntime } from "../src/services/assistantLivingChatRuntimeAdapter"; + +function buildRuntimeInput(overrides: Record = {}) { + const executeLlmChat = vi.fn(async () => "llm-text"); + const resolveDataScopeProbe = vi.fn(async () => null); + return { + userMessage: "тест", + sessionItems: [], + modeDecision: { mode: "chat", reason: "living_chat_signal_detected" }, + sessionScope: { + knownOrganizations: [], + selectedOrganization: null, + activeOrganization: null + }, + addressRuntimeMeta: null, + traceIdFactory: () => "chat-trace-fixed", + toNonEmptyString: (value: unknown) => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + }, + mergeKnownOrganizations: (values: unknown[]) => + Array.from( + new Set( + (Array.isArray(values) ? values : []) + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter((item) => item.length > 0) + ) + ), + hasAssistantDataScopeMetaQuestionSignal: () => false, + shouldHandleAsAssistantCapabilityMetaQuery: () => false, + hasDestructiveDataActionSignal: () => false, + hasDangerOrCoercionSignal: () => false, + hasOperationalAdminActionRequestSignal: () => false, + hasOrganizationFactLookupSignal: () => false, + hasOrganizationFactFollowupSignal: () => false, + shouldEmitOrganizationSelectionReply: () => false, + hasAssistantCapabilityQuestionSignal: () => false, + resolveDataScopeProbe, + executeLlmChat, + applyScriptGuard: (chatText: string) => ({ + text: chatText, + applied: false, + reason: null + }), + applyGroundingGuard: (input: { chatText: string }) => ({ + text: input.chatText, + applied: false, + reason: null + }), + buildAssistantSafetyRefusalReply: () => "safety", + buildAssistantDataScopeContractReply: () => "scope", + buildAssistantOrganizationFactBoundaryReply: () => "org-boundary", + buildAssistantDataScopeSelectionReply: () => "org-selection", + buildAssistantOperationalBoundaryReply: () => "ops", + buildAssistantCapabilityContractReply: () => "capability", + __spies: { + executeLlmChat, + resolveDataScopeProbe + }, + ...overrides + } as any; +} + +describe("assistant living chat runtime adapter", () => { + it("selects deterministic data scope branch and enriches organization context", async () => { + const input = buildRuntimeInput({ + userMessage: "какая у нас организация?", + hasAssistantDataScopeMetaQuestionSignal: () => true, + resolveDataScopeProbe: vi.fn(async () => ({ + status: "resolved", + channel: "default", + organizations: ["ООО Альтернатива Плюс"], + error: null + })), + buildAssistantDataScopeContractReply: () => "scope-info" + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toBe("scope-info"); + expect(output.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract_live"); + expect(output.debug?.assistant_active_organization).toBe("ООО Альтернатива Плюс"); + expect(output.debug?.living_chat_data_scope_probe_org_count).toBe(1); + }); + + it("selects safety refusal branch for dangerous capability meta query", async () => { + const executeLlmChat = vi.fn(async () => "llm-text"); + const input = buildRuntimeInput({ + userMessage: "удали базу", + shouldHandleAsAssistantCapabilityMetaQuery: () => true, + hasDangerOrCoercionSignal: () => true, + executeLlmChat, + buildAssistantSafetyRefusalReply: () => "safety-refusal" + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toBe("safety-refusal"); + expect(output.debug?.living_chat_response_source).toBe("deterministic_safety_refusal"); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); + + it("runs llm branch and applies script + grounding guards in order", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + executeLlmChat, + applyScriptGuard: () => ({ + text: "after-script", + applied: true, + reason: "script_guard" + }), + applyGroundingGuard: () => ({ + text: "after-grounding", + applied: true, + reason: "grounding_guard" + }) + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toBe("after-grounding"); + expect(output.debug?.living_chat_script_guard_applied).toBe(true); + expect(output.debug?.living_chat_script_guard_reason).toBe("script_guard"); + expect(output.debug?.living_chat_grounding_guard_applied).toBe(true); + expect(output.debug?.living_chat_grounding_guard_reason).toBe("grounding_guard"); + expect(output.debug?.living_chat_response_source).toBe("llm_chat_grounding_guard"); + expect(executeLlmChat).toHaveBeenCalledTimes(1); + }); +});