diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index c6a978c..810552d 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -1186,7 +1186,56 @@ Validation: - `assistantLivingRouter.test.ts` - `assistantWave10SettlementCorrectiveRegression.test.ts` -Status: **In progress (Phase 2.1 + 2.2 + 2.3 + 2.4 + 2.5 + 2.6 + 2.7 + 2.8 + 2.9 + 2.10 + 2.11 + 2.12 + 2.13 + 2.14 + 2.15 + 2.16 + 2.17 + 2.18 + 2.19 + 2.20 + 2.21 + 2.22 + 2.23 + 2.24 + 2.25 + 2.26 + 2.27 + 2.28 + 2.29 + 2.30 + 2.31 + 2.32 + 2.33 + 2.34 + 2.35 + 2.36 completed)** +Implemented in current pass (Phase 2.37): +1. Extracted living-chat LLM call/build block from `assistantService` into dedicated runtime adapter: + - `assistantLivingChatLlmRuntimeAdapter.ts` + - introduced: + - `runAssistantLivingChatLlmRuntime(...)` +2. Centralized living LLM execution sequence (behavior-preserving): + - context window assembly from session history; + - canon excerpt loading and prompt composition; + - model/token selection with guard clamp; + - output sanitization with stable fallback. +3. Rewired `assistantService` `tryHandleLivingChat(...)` closure to consume the new LLM runtime adapter. +4. Added focused unit tests: + - `assistantLivingChatLlmRuntimeAdapter.test.ts` + +Validation: +1. `npm run build` passed. +2. Targeted living/address/deep followup pack passed: + - `assistantLivingChatLlmRuntimeAdapter.test.ts` + - `assistantLivingChatHandlerRuntimeAdapter.test.ts` + - `assistantLivingChatRuntimeAdapter.test.ts` + - `assistantAddressRuntimeAdapter.test.ts` + - `assistantAddressLaneResponseRuntimeAdapter.test.ts` + - `assistantDeepTurnResponseRuntimeAdapter.test.ts` + - `assistantDeepTurnPackagingRuntimeAdapter.test.ts` + - `assistantWave10SettlementCorrectiveRegression.test.ts` + +Implemented in current pass (Phase 2.38): +1. Internalized living-chat prompt/context builders into `assistantLivingChatLlmRuntimeAdapter`: + - removed external builder dependencies from runtime input contract; + - centralized compacting + clipping + context/prompt projection directly in adapter. +2. Simplified `assistantService` living LLM call wiring: + - removed `buildLivingChatContextWindow` / `buildLivingChatPrompt` injection from `executeLlmChat`. +3. Updated focused tests for adapter-owned prompt assembly: + - `assistantLivingChatLlmRuntimeAdapter.test.ts` now verifies context carryover and user-message prompt shape. +4. Applied cleanup inside `assistantService` after the wiring simplification: + - removed dead local living prompt/context helper functions. + +Validation: +1. `npm run build` passed. +2. Targeted living/address/deep followup pack passed: + - `assistantLivingChatLlmRuntimeAdapter.test.ts` + - `assistantLivingChatHandlerRuntimeAdapter.test.ts` + - `assistantLivingChatRuntimeAdapter.test.ts` + - `assistantAddressRuntimeAdapter.test.ts` + - `assistantAddressLaneResponseRuntimeAdapter.test.ts` + - `assistantDeepTurnResponseRuntimeAdapter.test.ts` + - `assistantDeepTurnPackagingRuntimeAdapter.test.ts` + - `assistantWave10SettlementCorrectiveRegression.test.ts` + +Status: **In progress (Phase 2.1 + 2.2 + 2.3 + 2.4 + 2.5 + 2.6 + 2.7 + 2.8 + 2.9 + 2.10 + 2.11 + 2.12 + 2.13 + 2.14 + 2.15 + 2.16 + 2.17 + 2.18 + 2.19 + 2.20 + 2.21 + 2.22 + 2.23 + 2.24 + 2.25 + 2.26 + 2.27 + 2.28 + 2.29 + 2.30 + 2.31 + 2.32 + 2.33 + 2.34 + 2.35 + 2.36 + 2.37 + 2.38 completed)** ## Stage 3 (P2): Hybrid Semantic Layer (LLM + Deterministic Guards) diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatLlmRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatLlmRuntimeAdapter.js new file mode 100644 index 0000000..b6a8e4b --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantLivingChatLlmRuntimeAdapter.js @@ -0,0 +1,69 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runAssistantLivingChatLlmRuntime = runAssistantLivingChatLlmRuntime; +const DEFAULT_LIVING_CHAT_TEMPERATURE = 0.35; +const DEFAULT_LIVING_CHAT_MAX_OUTPUT_TOKENS = 420; +const LIVING_CHAT_MAX_TOKENS_MIN = 120; +const LIVING_CHAT_MAX_TOKENS_MAX = 900; +const LIVING_CHAT_SYSTEM_PROMPT_PARTS = [ + 'РўС‹ Р¶РёРІРѕР№ русскоязычный ассистент для чтения Рё анализа данных 1РЎ.', + 'Работай честно: РЅРµ заявляй действия, которые недоступны РІ этом рантайме.', + 'Разрешено: анализ Рё объяснение данных, формулировка запросов, подсказки РїРѕ следующему шагу.', + 'Запрещено: обещать настройку 1РЎ, админ-действия, создание/проведение документов или любые изменения РІ базе.', + 'Если пользователь спрашивает РїСЂРѕ возможности, отвечай только РїРѕ этому контракту.' +]; +const LIVING_CHAT_DEVELOPER_PROMPT = 'Формат: коротко Рё РїРѕ сути, без JSON Рё без служебных блоков. Пиши человеко-понятно.'; +const LIVING_CHAT_FALLBACK_REPLY = 'РџРѕРЅСЏР». Сформулируйте, что именно РЅСѓР¶РЅРѕ РїРѕ данным 1РЎ, Рё СЏ РїРѕРјРѕРіСѓ РїРѕ шагам.'; +function clampLivingChatMaxOutputTokens(value) { + const numeric = Number(value ?? DEFAULT_LIVING_CHAT_MAX_OUTPUT_TOKENS); + return Math.max(LIVING_CHAT_MAX_TOKENS_MIN, Math.min(numeric, LIVING_CHAT_MAX_TOKENS_MAX)); +} +function compactWhitespace(value) { + return String(value ?? "") + .replace(/\s+/g, " ") + .trim(); +} +function buildLivingChatContextWindow(items) { + const source = Array.isArray(items) ? items.slice(-6) : []; + const lines = []; + for (const item of source) { + if (!item || typeof item !== "object") { + continue; + } + const role = String(item.role ?? "").trim(); + const text = compactWhitespace(String(item.text ?? "")); + if (!role || !text) { + continue; + } + const clipped = text.length > 220 ? `${text.slice(0, 220)}...` : text; + lines.push(`${role}: ${clipped}`); + } + return lines.join("\n"); +} +function buildLivingChatPrompt(userMessage, conversationWindow) { + const contextBlock = conversationWindow ? `Контекст последних сообщений:\n${conversationWindow}\n\n` : ""; + return `${contextBlock}Сообщение пользователя:\n${userMessage}`; +} +async function runAssistantLivingChatLlmRuntime(input) { + const conversationWindow = buildLivingChatContextWindow(input.sessionItems); + const userPrompt = buildLivingChatPrompt(input.userMessage, conversationWindow); + const canonExcerpt = input.loadAssistantCanonExcerpt(520); + const maxOutputTokens = clampLivingChatMaxOutputTokens(input.payload.maxOutputTokens); + const temperature = input.payload.temperature ?? DEFAULT_LIVING_CHAT_TEMPERATURE; + const systemPrompt = [...LIVING_CHAT_SYSTEM_PROMPT_PARTS, `Канон поведения: ${canonExcerpt}`].join(" "); + const chatResponse = await input.chatClient.chat({ + llmProvider: input.payload.llmProvider, + apiKey: String(input.payload.apiKey ?? input.defaultApiKey ?? ""), + model: String(input.payload.model ?? input.defaultModel), + baseUrl: input.payload.baseUrl ?? input.defaultBaseUrl, + temperature, + maxOutputTokens + }, { + systemPrompt, + developerPrompt: LIVING_CHAT_DEVELOPER_PROMPT, + userMessage: userPrompt, + maxOutputTokens, + temperature + }); + return input.sanitizeOutgoingAssistantText(chatResponse?.outputText ?? "", LIVING_CHAT_FALLBACK_REPLY); +} diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 7691968..d6a1f25 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -80,6 +80,7 @@ const assistantDeepTurnResponseRuntimeAdapter_1 = __importStar(require("./assist const assistantDeepTurnRetrievalRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnRetrievalRuntimeAdapter")); const assistantAddressRuntimeAdapter_1 = __importStar(require("./assistantAddressRuntimeAdapter")); const assistantLivingChatHandlerRuntimeAdapter_1 = __importStar(require("./assistantLivingChatHandlerRuntimeAdapter")); +const assistantLivingChatLlmRuntimeAdapter_1 = __importStar(require("./assistantLivingChatLlmRuntimeAdapter")); const assistantQueryPlanning_1 = __importStar(require("./assistantQueryPlanning")); const iconv_lite_1 = __importDefault(require("iconv-lite")); const DATA_SCOPE_CACHE_TTL_MS = 60_000; @@ -3469,29 +3470,6 @@ function hasLivingChatSignal(text) { } return hasSmallTalkSignal(lower); } -function buildLivingChatContextWindow(items) { - const source = Array.isArray(items) ? items.slice(-6) : []; - const lines = []; - for (const item of source) { - if (!item || typeof item !== "object") { - continue; - } - const role = String(item.role ?? "").trim(); - const text = compactWhitespace(String(item.text ?? "")); - if (!role || !text) { - continue; - } - const clipped = text.length > 220 ? `${text.slice(0, 220)}...` : text; - lines.push(`${role}: ${clipped}`); - } - return lines.join("\n"); -} -function buildLivingChatPrompt(userMessage, conversationWindow) { - const contextBlock = conversationWindow - ? `Контекст последних сообщений:\n${conversationWindow}\n\n` - : ""; - return `${contextBlock}Сообщение пользователя:\n${userMessage}`; -} function buildAssistantCapabilityContractReply() { return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)(); } @@ -4477,33 +4455,17 @@ class AssistantService { 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РЎ, Рё СЏ РїРѕРјРѕРіСѓ РїРѕ шагам.'); - }, + executeLlmChat: async () => (0, assistantLivingChatLlmRuntimeAdapter_1.runAssistantLivingChatLlmRuntime)({ + userMessage, + sessionItems: session.items, + payload, + chatClient: this.chatClient, + loadAssistantCanonExcerpt: assistantCanon_1.loadAssistantCanonExcerpt, + sanitizeOutgoingAssistantText, + defaultModel: config_1.DEFAULT_MODEL, + defaultBaseUrl: config_1.DEFAULT_OPENAI_BASE_URL, + defaultApiKey: process.env.OPENAI_API_KEY ?? "" + }), applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage), applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput), buildAssistantSafetyRefusalReply, diff --git a/llm_normalizer/backend/src/services/assistantLivingChatLlmRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatLlmRuntimeAdapter.ts new file mode 100644 index 0000000..2eef6b8 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantLivingChatLlmRuntimeAdapter.ts @@ -0,0 +1,120 @@ +const DEFAULT_LIVING_CHAT_TEMPERATURE = 0.35; +const DEFAULT_LIVING_CHAT_MAX_OUTPUT_TOKENS = 420; +const LIVING_CHAT_MAX_TOKENS_MIN = 120; +const LIVING_CHAT_MAX_TOKENS_MAX = 900; + +const LIVING_CHAT_SYSTEM_PROMPT_PARTS = [ + 'РўС‹ Р¶РёРІРѕР№ русскоязычный ассистент для чтения Рё анализа данных 1РЎ.', + 'Работай честно: РЅРµ заявляй действия, которые недоступны РІ этом рантайме.', + 'Разрешено: анализ Рё объяснение данных, формулировка запросов, подсказки РїРѕ следующему шагу.', + 'Запрещено: обещать настройку 1РЎ, админ-действия, создание/проведение документов или любые изменения РІ базе.', + 'Если пользователь спрашивает РїСЂРѕ возможности, отвечай только РїРѕ этому контракту.' +]; + +const LIVING_CHAT_DEVELOPER_PROMPT = + 'Формат: коротко Рё РїРѕ сути, без JSON Рё без служебных блоков. Пиши человеко-понятно.'; + +const LIVING_CHAT_FALLBACK_REPLY = + 'РџРѕРЅСЏР». Сформулируйте, что именно РЅСѓР¶РЅРѕ РїРѕ данным 1РЎ, Рё СЏ РїРѕРјРѕРіСѓ РїРѕ шагам.'; + +export interface RunAssistantLivingChatLlmRuntimeInput { + userMessage: string; + sessionItems: unknown[]; + payload: { + llmProvider?: unknown; + apiKey?: unknown; + model?: unknown; + baseUrl?: unknown; + temperature?: number; + maxOutputTokens?: number; + }; + chatClient: { + chat: ( + config: { + llmProvider: unknown; + apiKey: string; + model: string; + baseUrl: unknown; + temperature: number; + maxOutputTokens: number; + }, + prompt: { + systemPrompt: string; + developerPrompt: string; + userMessage: string; + maxOutputTokens: number; + temperature: number; + } + ) => Promise<{ outputText?: string | null } | null>; + }; + loadAssistantCanonExcerpt: (maxChars: number) => string; + sanitizeOutgoingAssistantText: (text: unknown, fallback?: string) => string; + defaultModel: string; + defaultBaseUrl: string; + defaultApiKey?: string; +} + +function clampLivingChatMaxOutputTokens(value: unknown): number { + const numeric = Number(value ?? DEFAULT_LIVING_CHAT_MAX_OUTPUT_TOKENS); + return Math.max(LIVING_CHAT_MAX_TOKENS_MIN, Math.min(numeric, LIVING_CHAT_MAX_TOKENS_MAX)); +} + +function compactWhitespace(value: unknown): string { + return String(value ?? "") + .replace(/\s+/g, " ") + .trim(); +} + +function buildLivingChatContextWindow(items: unknown[]): string { + const source = Array.isArray(items) ? items.slice(-6) : []; + const lines: string[] = []; + for (const item of source) { + if (!item || typeof item !== "object") { + continue; + } + const role = String((item as { role?: unknown }).role ?? "").trim(); + const text = compactWhitespace(String((item as { text?: unknown }).text ?? "")); + if (!role || !text) { + continue; + } + const clipped = text.length > 220 ? `${text.slice(0, 220)}...` : text; + lines.push(`${role}: ${clipped}`); + } + return lines.join("\n"); +} + +function buildLivingChatPrompt(userMessage: string, conversationWindow: string): string { + const contextBlock = conversationWindow ? `Контекст последних сообщений:\n${conversationWindow}\n\n` : ""; + return `${contextBlock}Сообщение пользователя:\n${userMessage}`; +} + +export async function runAssistantLivingChatLlmRuntime( + input: RunAssistantLivingChatLlmRuntimeInput +): Promise { + const conversationWindow = buildLivingChatContextWindow(input.sessionItems); + const userPrompt = buildLivingChatPrompt(input.userMessage, conversationWindow); + const canonExcerpt = input.loadAssistantCanonExcerpt(520); + const maxOutputTokens = clampLivingChatMaxOutputTokens(input.payload.maxOutputTokens); + const temperature = input.payload.temperature ?? DEFAULT_LIVING_CHAT_TEMPERATURE; + const systemPrompt = [...LIVING_CHAT_SYSTEM_PROMPT_PARTS, `Канон поведения: ${canonExcerpt}`].join(" "); + + const chatResponse = await input.chatClient.chat( + { + llmProvider: input.payload.llmProvider, + apiKey: String(input.payload.apiKey ?? input.defaultApiKey ?? ""), + model: String(input.payload.model ?? input.defaultModel), + baseUrl: input.payload.baseUrl ?? input.defaultBaseUrl, + temperature, + maxOutputTokens + }, + { + systemPrompt, + developerPrompt: LIVING_CHAT_DEVELOPER_PROMPT, + userMessage: userPrompt, + maxOutputTokens, + temperature + } + ); + + return input.sanitizeOutgoingAssistantText(chatResponse?.outputText ?? "", LIVING_CHAT_FALLBACK_REPLY); +} diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 770bda2..d421265 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -34,6 +34,7 @@ import * as assistantDeepTurnResponseRuntimeAdapter_1 from "./assistantDeepTurnR import * as assistantDeepTurnRetrievalRuntimeAdapter_1 from "./assistantDeepTurnRetrievalRuntimeAdapter"; import * as assistantAddressRuntimeAdapter_1 from "./assistantAddressRuntimeAdapter"; import * as assistantLivingChatHandlerRuntimeAdapter_1 from "./assistantLivingChatHandlerRuntimeAdapter"; +import * as assistantLivingChatLlmRuntimeAdapter_1 from "./assistantLivingChatLlmRuntimeAdapter"; import * as assistantQueryPlanning_1 from "./assistantQueryPlanning"; import iconv from "iconv-lite"; const DATA_SCOPE_CACHE_TTL_MS = 60_000; @@ -3425,29 +3426,6 @@ function hasLivingChatSignal(text) { } return hasSmallTalkSignal(lower); } -function buildLivingChatContextWindow(items) { - const source = Array.isArray(items) ? items.slice(-6) : []; - const lines = []; - for (const item of source) { - if (!item || typeof item !== "object") { - continue; - } - const role = String(item.role ?? "").trim(); - const text = compactWhitespace(String(item.text ?? "")); - if (!role || !text) { - continue; - } - const clipped = text.length > 220 ? `${text.slice(0, 220)}...` : text; - lines.push(`${role}: ${clipped}`); - } - return lines.join("\n"); -} -function buildLivingChatPrompt(userMessage, conversationWindow) { - const contextBlock = conversationWindow - ? `Контекст последних сообщений:\n${conversationWindow}\n\n` - : ""; - return `${contextBlock}Сообщение пользователя:\n${userMessage}`; -} function buildAssistantCapabilityContractReply() { return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)(); } @@ -4432,33 +4410,17 @@ export class AssistantService { 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РЎ, Рё СЏ РїРѕРјРѕРіСѓ РїРѕ шагам.'); - }, + executeLlmChat: async () => (0, assistantLivingChatLlmRuntimeAdapter_1.runAssistantLivingChatLlmRuntime)({ + userMessage, + sessionItems: session.items, + payload, + chatClient: this.chatClient, + loadAssistantCanonExcerpt: assistantCanon_1.loadAssistantCanonExcerpt, + sanitizeOutgoingAssistantText, + defaultModel: config_1.DEFAULT_MODEL, + defaultBaseUrl: config_1.DEFAULT_OPENAI_BASE_URL, + defaultApiKey: process.env.OPENAI_API_KEY ?? "" + }), applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage), applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput), buildAssistantSafetyRefusalReply, @@ -4690,5 +4652,3 @@ export class AssistantService { return deepTurnResponseRuntime.response; } } - - diff --git a/llm_normalizer/backend/tests/assistantLivingChatLlmRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatLlmRuntimeAdapter.test.ts new file mode 100644 index 0000000..94e10c3 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantLivingChatLlmRuntimeAdapter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { runAssistantLivingChatLlmRuntime } from "../src/services/assistantLivingChatLlmRuntimeAdapter"; + +describe("assistant living chat llm runtime adapter", () => { + it("builds prompt/context internally and calls chat client with bounded output tokens", async () => { + const chatClient = { + chat: vi.fn(async () => ({ + outputText: " llm-answer " + })) + }; + const loadAssistantCanonExcerpt = vi.fn(() => "canon-chunk"); + const sanitizeOutgoingAssistantText = vi.fn((value: unknown) => String(value ?? "").trim()); + + const result = await runAssistantLivingChatLlmRuntime({ + userMessage: "question", + sessionItems: [{ role: "user", text: "prev" }], + payload: { + llmProvider: "openai", + apiKey: "k", + model: "m", + baseUrl: "https://api.example.com", + temperature: 0.2, + maxOutputTokens: 5000 + }, + chatClient, + loadAssistantCanonExcerpt, + sanitizeOutgoingAssistantText, + defaultModel: "gpt-default", + defaultBaseUrl: "https://api.default.com", + defaultApiKey: "fallback-key" + }); + + expect(loadAssistantCanonExcerpt).toHaveBeenCalledWith(520); + expect(chatClient.chat).toHaveBeenCalledWith( + expect.objectContaining({ + llmProvider: "openai", + apiKey: "k", + model: "m", + baseUrl: "https://api.example.com", + temperature: 0.2, + maxOutputTokens: 900 + }), + expect.objectContaining({ + userMessage: expect.stringContaining("Сообщение пользователя:\nquestion"), + maxOutputTokens: 900, + temperature: 0.2, + systemPrompt: expect.stringContaining("canon-chunk") + }) + ); + const promptPayload = chatClient.chat.mock.calls[0]?.[1]; + expect(promptPayload.userMessage).toContain("user: prev"); + expect(sanitizeOutgoingAssistantText).toHaveBeenCalledWith(" llm-answer ", expect.any(String)); + expect(result).toBe("llm-answer"); + }); + + it("uses defaults and fallback text when model output is empty", async () => { + const sanitizeOutgoingAssistantText = vi.fn((_value: unknown, fallback?: string) => String(fallback ?? "")); + const result = await runAssistantLivingChatLlmRuntime({ + userMessage: "question", + sessionItems: [], + payload: {}, + chatClient: { + chat: async () => ({ + outputText: "" + }) + }, + loadAssistantCanonExcerpt: () => "canon", + sanitizeOutgoingAssistantText, + defaultModel: "gpt-default", + defaultBaseUrl: "https://api.default.com", + defaultApiKey: "fallback-key" + }); + + expect(sanitizeOutgoingAssistantText).toHaveBeenCalledWith("", expect.any(String)); + expect(result.length).toBeGreaterThan(0); + }); +});