ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Рефакторинг этапов 2.37 - вынос LLM-вызов живого чата из assistantService (большой prompt-блок) в отдельный адаптер, чтобы assistantService стал заметно чище.
This commit is contained in:
parent
be116dcbde
commit
0cc8f71068
|
|
@ -1186,7 +1186,56 @@ Validation:
|
||||||
- `assistantLivingRouter.test.ts`
|
- `assistantLivingRouter.test.ts`
|
||||||
- `assistantWave10SettlementCorrectiveRegression.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)
|
## Stage 3 (P2): Hybrid Semantic Layer (LLM + Deterministic Guards)
|
||||||
|
|
||||||
|
|
|
||||||
69
llm_normalizer/backend/dist/services/assistantLivingChatLlmRuntimeAdapter.js
vendored
Normal file
69
llm_normalizer/backend/dist/services/assistantLivingChatLlmRuntimeAdapter.js
vendored
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,7 @@ const assistantDeepTurnResponseRuntimeAdapter_1 = __importStar(require("./assist
|
||||||
const assistantDeepTurnRetrievalRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnRetrievalRuntimeAdapter"));
|
const assistantDeepTurnRetrievalRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnRetrievalRuntimeAdapter"));
|
||||||
const assistantAddressRuntimeAdapter_1 = __importStar(require("./assistantAddressRuntimeAdapter"));
|
const assistantAddressRuntimeAdapter_1 = __importStar(require("./assistantAddressRuntimeAdapter"));
|
||||||
const assistantLivingChatHandlerRuntimeAdapter_1 = __importStar(require("./assistantLivingChatHandlerRuntimeAdapter"));
|
const assistantLivingChatHandlerRuntimeAdapter_1 = __importStar(require("./assistantLivingChatHandlerRuntimeAdapter"));
|
||||||
|
const assistantLivingChatLlmRuntimeAdapter_1 = __importStar(require("./assistantLivingChatLlmRuntimeAdapter"));
|
||||||
const assistantQueryPlanning_1 = __importStar(require("./assistantQueryPlanning"));
|
const assistantQueryPlanning_1 = __importStar(require("./assistantQueryPlanning"));
|
||||||
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
||||||
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
||||||
|
|
@ -3469,29 +3470,6 @@ function hasLivingChatSignal(text) {
|
||||||
}
|
}
|
||||||
return hasSmallTalkSignal(lower);
|
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() {
|
function buildAssistantCapabilityContractReply() {
|
||||||
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
|
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
|
||||||
}
|
}
|
||||||
|
|
@ -4477,33 +4455,17 @@ class AssistantService {
|
||||||
shouldEmitOrganizationSelectionReply,
|
shouldEmitOrganizationSelectionReply,
|
||||||
hasAssistantCapabilityQuestionSignal,
|
hasAssistantCapabilityQuestionSignal,
|
||||||
resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(),
|
resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(),
|
||||||
executeLlmChat: async () => {
|
executeLlmChat: async () => (0, assistantLivingChatLlmRuntimeAdapter_1.runAssistantLivingChatLlmRuntime)({
|
||||||
const conversationWindow = buildLivingChatContextWindow(session.items);
|
userMessage,
|
||||||
const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow);
|
sessionItems: session.items,
|
||||||
const canonExcerpt = (0, assistantCanon_1.loadAssistantCanonExcerpt)(520);
|
payload,
|
||||||
const chatResponse = await this.chatClient.chat({
|
chatClient: this.chatClient,
|
||||||
llmProvider: payload.llmProvider,
|
loadAssistantCanonExcerpt: assistantCanon_1.loadAssistantCanonExcerpt,
|
||||||
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ''),
|
sanitizeOutgoingAssistantText,
|
||||||
model: String(payload.model ?? config_1.DEFAULT_MODEL),
|
defaultModel: config_1.DEFAULT_MODEL,
|
||||||
baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL,
|
defaultBaseUrl: config_1.DEFAULT_OPENAI_BASE_URL,
|
||||||
temperature: payload.temperature ?? 0.35,
|
defaultApiKey: process.env.OPENAI_API_KEY ?? ""
|
||||||
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),
|
applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage),
|
||||||
applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput),
|
applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput),
|
||||||
buildAssistantSafetyRefusalReply,
|
buildAssistantSafetyRefusalReply,
|
||||||
|
|
|
||||||
|
|
@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import * as assistantDeepTurnResponseRuntimeAdapter_1 from "./assistantDeepTurnR
|
||||||
import * as assistantDeepTurnRetrievalRuntimeAdapter_1 from "./assistantDeepTurnRetrievalRuntimeAdapter";
|
import * as assistantDeepTurnRetrievalRuntimeAdapter_1 from "./assistantDeepTurnRetrievalRuntimeAdapter";
|
||||||
import * as assistantAddressRuntimeAdapter_1 from "./assistantAddressRuntimeAdapter";
|
import * as assistantAddressRuntimeAdapter_1 from "./assistantAddressRuntimeAdapter";
|
||||||
import * as assistantLivingChatHandlerRuntimeAdapter_1 from "./assistantLivingChatHandlerRuntimeAdapter";
|
import * as assistantLivingChatHandlerRuntimeAdapter_1 from "./assistantLivingChatHandlerRuntimeAdapter";
|
||||||
|
import * as assistantLivingChatLlmRuntimeAdapter_1 from "./assistantLivingChatLlmRuntimeAdapter";
|
||||||
import * as assistantQueryPlanning_1 from "./assistantQueryPlanning";
|
import * as assistantQueryPlanning_1 from "./assistantQueryPlanning";
|
||||||
import iconv from "iconv-lite";
|
import iconv from "iconv-lite";
|
||||||
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
const DATA_SCOPE_CACHE_TTL_MS = 60_000;
|
||||||
|
|
@ -3425,29 +3426,6 @@ function hasLivingChatSignal(text) {
|
||||||
}
|
}
|
||||||
return hasSmallTalkSignal(lower);
|
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() {
|
function buildAssistantCapabilityContractReply() {
|
||||||
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
|
return (0, capabilitiesRegistry_1.buildCapabilityContractReplyFromRegistry)();
|
||||||
}
|
}
|
||||||
|
|
@ -4432,33 +4410,17 @@ export class AssistantService {
|
||||||
shouldEmitOrganizationSelectionReply,
|
shouldEmitOrganizationSelectionReply,
|
||||||
hasAssistantCapabilityQuestionSignal,
|
hasAssistantCapabilityQuestionSignal,
|
||||||
resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(),
|
resolveDataScopeProbe: () => resolveAssistantDataScopeProbe(),
|
||||||
executeLlmChat: async () => {
|
executeLlmChat: async () => (0, assistantLivingChatLlmRuntimeAdapter_1.runAssistantLivingChatLlmRuntime)({
|
||||||
const conversationWindow = buildLivingChatContextWindow(session.items);
|
userMessage,
|
||||||
const userPrompt = buildLivingChatPrompt(userMessage, conversationWindow);
|
sessionItems: session.items,
|
||||||
const canonExcerpt = (0, assistantCanon_1.loadAssistantCanonExcerpt)(520);
|
payload,
|
||||||
const chatResponse = await this.chatClient.chat({
|
chatClient: this.chatClient,
|
||||||
llmProvider: payload.llmProvider,
|
loadAssistantCanonExcerpt: assistantCanon_1.loadAssistantCanonExcerpt,
|
||||||
apiKey: String(payload.apiKey ?? process.env.OPENAI_API_KEY ?? ''),
|
sanitizeOutgoingAssistantText,
|
||||||
model: String(payload.model ?? config_1.DEFAULT_MODEL),
|
defaultModel: config_1.DEFAULT_MODEL,
|
||||||
baseUrl: payload.baseUrl ?? config_1.DEFAULT_OPENAI_BASE_URL,
|
defaultBaseUrl: config_1.DEFAULT_OPENAI_BASE_URL,
|
||||||
temperature: payload.temperature ?? 0.35,
|
defaultApiKey: process.env.OPENAI_API_KEY ?? ""
|
||||||
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),
|
applyScriptGuard: (chatText, runtimeUserMessage) => applyLivingChatScriptGuard(chatText, runtimeUserMessage),
|
||||||
applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput),
|
applyGroundingGuard: (guardInput) => applyLivingChatGroundingGuard(guardInput),
|
||||||
buildAssistantSafetyRefusalReply,
|
buildAssistantSafetyRefusalReply,
|
||||||
|
|
@ -4690,5 +4652,3 @@ export class AssistantService {
|
||||||
return deepTurnResponseRuntime.response;
|
return deepTurnResponseRuntime.response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue