diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index 3811deb..1566ef1 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -895,7 +895,34 @@ 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 completed)** +Implemented in current pass (Phase 2.26): +1. Extracted living-chat finalization/response tail from `assistantService` into dedicated runtime adapter: + - `assistantLivingChatTurnFinalizeRuntimeAdapter.ts` + - introduced: + - `finalizeAssistantLivingChatTurn(...)` +2. Centralized living-chat finalization runtime sequence (behavior-preserving): + - assistant item creation for chat lane; + - structured `assistant_message_chat` processed-event payload build; + - turn commit/persist/log via shared commit runtime adapter; + - API success response assembly from committed conversation state. +3. Rewired `assistantService` `tryHandleLivingChat(...)` finalize path to consume adapter output (behavior-preserving). +4. Added focused unit tests: + - `assistantLivingChatTurnFinalizeRuntimeAdapter.test.ts` + +Validation: +1. `npm run build` passed. +2. Targeted living/address/deep finalize pack passed: + - `assistantLivingChatTurnFinalizeRuntimeAdapter.test.ts` + - `assistantAddressTurnFinalizeRuntimeAdapter.test.ts` + - `assistantDeepTurnFinalizeRuntimeAdapter.test.ts` + - `assistantLivingChatMode.test.ts` + - `assistantLivingRouter.test.ts` +3. Additional safety regressions passed: + - `assistantWave10SettlementCorrectiveRegression.test.ts` + - `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)** ## Stage 3 (P2): Hybrid Semantic Layer (LLM + Deterministic Guards) diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatTurnFinalizeRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatTurnFinalizeRuntimeAdapter.js new file mode 100644 index 0000000..89b1807 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantLivingChatTurnFinalizeRuntimeAdapter.js @@ -0,0 +1,63 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.finalizeAssistantLivingChatTurn = finalizeAssistantLivingChatTurn; +const nanoid_1 = require("nanoid"); +const assistantTurnCommitRuntimeAdapter_1 = require("./assistantTurnCommitRuntimeAdapter"); +function toTraceId(debug) { + const value = debug?.trace_id; + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} +function finalizeAssistantLivingChatTurn(input) { + const nowIso = input.nowIso ?? (() => new Date().toISOString()); + const messageIdFactory = input.messageIdFactory ?? (() => `msg-${(0, nanoid_1.nanoid)(10)}`); + const commitSafe = input.commitFn ?? assistantTurnCommitRuntimeAdapter_1.commitAssistantTurnAndLog; + const assistantItem = { + message_id: messageIdFactory(), + session_id: input.sessionId, + role: "assistant", + text: input.assistantReply, + reply_type: input.replyType, + created_at: nowIso(), + trace_id: toTraceId(input.debug), + debug: input.debug + }; + const logDetails = { + session_id: input.sessionId, + message_id: assistantItem.message_id, + user_message: input.userMessage, + living_router_mode: input.modeDecision?.mode ?? "chat", + living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected", + assistant_reply: assistantItem.text, + reply_type: assistantItem.reply_type, + trace_id: assistantItem.trace_id + }; + const commitResult = commitSafe({ + sessionId: input.sessionId, + assistantItem, + eventType: "assistant_message_chat", + logDetails, + appendItem: input.appendItem, + getSession: input.getSession, + persistSession: input.persistSession, + cloneConversation: input.cloneConversation, + logEvent: input.logEvent + }); + const response = { + ok: true, + session_id: input.sessionId, + assistant_reply: assistantItem.text, + reply_type: assistantItem.reply_type, + conversation_item: assistantItem, + debug: input.debug, + conversation: commitResult.conversation + }; + return { + assistantItem, + commitResult, + response + }; +} diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 623c118..aad0f9f 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -75,6 +75,7 @@ const assistantDeepTurnGroundingRuntimeAdapter_1 = __importStar(require("./assis const assistantDeepTurnPackagingRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnPackagingRuntimeAdapter")); const assistantDeepTurnPlanRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnPlanRuntimeAdapter")); const assistantDeepTurnRetrievalRuntimeAdapter_1 = __importStar(require("./assistantDeepTurnRetrievalRuntimeAdapter")); +const assistantLivingChatTurnFinalizeRuntimeAdapter_1 = __importStar(require("./assistantLivingChatTurnFinalizeRuntimeAdapter")); const assistantQueryPlanning_1 = __importStar(require("./assistantQueryPlanning")); const iconv_lite_1 = __importDefault(require("iconv-lite")); const DATA_SCOPE_CACHE_TTL_MS = 60_000; @@ -4604,49 +4605,21 @@ class AssistantService { normalized: null, normalizer_output: null }; - const assistantItem = { - message_id: `msg-${(0, nanoid_1.nanoid)(10)}`, - session_id: sessionId, - role: "assistant", - text: chatText, - reply_type: "factual_with_explanation", - created_at: new Date().toISOString(), - trace_id: debug.trace_id, - debug - }; - this.sessions.appendItem(sessionId, assistantItem); - const current = this.sessions.getSession(sessionId); - if (current) { - this.sessionLogger.persistSession(current); - } - const conversation = cloneItems(current?.items ?? []); - (0, log_1.logJson)({ - timestamp: new Date().toISOString(), - level: "info", - service: "assistant_loop", - message: "assistant_message_processed", + const finalization = (0, assistantLivingChatTurnFinalizeRuntimeAdapter_1.finalizeAssistantLivingChatTurn)({ sessionId, - eventType: "assistant_message_chat", - details: { - session_id: sessionId, - message_id: assistantItem.message_id, - user_message: userMessage, - living_router_mode: modeDecision?.mode ?? "chat", - living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected", - assistant_reply: assistantItem.text, - reply_type: assistantItem.reply_type, - trace_id: assistantItem.trace_id - } - }); - return { - ok: true, - session_id: sessionId, - assistant_reply: assistantItem.text, - reply_type: assistantItem.reply_type, - conversation_item: assistantItem, + userMessage, + assistantReply: chatText, + replyType: "factual_with_explanation", debug, - conversation - }; + modeDecision, + appendItem: (targetSessionId, item) => this.sessions.appendItem(targetSessionId, item), + getSession: (targetSessionId) => this.sessions.getSession(targetSessionId), + persistSession: (sessionState) => this.sessionLogger.persistSession(sessionState), + cloneConversation: (items) => cloneItems(items), + logEvent: (payload) => (0, log_1.logJson)(payload), + messageIdFactory: () => `msg-${(0, nanoid_1.nanoid)(10)}` + }); + return finalization.response; } catch (error) { (0, log_1.logJson)({ diff --git a/llm_normalizer/backend/src/services/assistantLivingChatTurnFinalizeRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatTurnFinalizeRuntimeAdapter.ts new file mode 100644 index 0000000..f9d5719 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantLivingChatTurnFinalizeRuntimeAdapter.ts @@ -0,0 +1,99 @@ +import { nanoid } from "nanoid"; +import type { + AssistantConversationItem, + AssistantDebugPayload, + AssistantMessageResponsePayload, + AssistantReplyType +} from "../types/assistant"; +import type { CommitAssistantTurnAndLogOutput } from "./assistantTurnCommitRuntimeAdapter"; +import { commitAssistantTurnAndLog } from "./assistantTurnCommitRuntimeAdapter"; + +export interface LivingModeDecisionForFinalize { + mode?: string | null; + reason?: string | null; +} + +export interface FinalizeAssistantLivingChatTurnInput { + sessionId: string; + userMessage: string; + assistantReply: string; + replyType: AssistantReplyType; + debug: AssistantDebugPayload | Record; + modeDecision?: LivingModeDecisionForFinalize | null; + appendItem: Parameters[0]["appendItem"]; + getSession: Parameters[0]["getSession"]; + persistSession: Parameters[0]["persistSession"]; + cloneConversation: Parameters[0]["cloneConversation"]; + logEvent: Parameters[0]["logEvent"]; + messageIdFactory?: () => string; + nowIso?: () => string; + commitFn?: typeof commitAssistantTurnAndLog; +} + +export interface FinalizeAssistantLivingChatTurnOutput { + assistantItem: AssistantConversationItem; + commitResult: CommitAssistantTurnAndLogOutput; + response: AssistantMessageResponsePayload; +} + +function toTraceId(debug: AssistantDebugPayload | Record): string | null { + const value = (debug as Record | null | undefined)?.trace_id; + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function finalizeAssistantLivingChatTurn( + input: FinalizeAssistantLivingChatTurnInput +): FinalizeAssistantLivingChatTurnOutput { + const nowIso = input.nowIso ?? (() => new Date().toISOString()); + const messageIdFactory = input.messageIdFactory ?? (() => `msg-${nanoid(10)}`); + const commitSafe = input.commitFn ?? commitAssistantTurnAndLog; + const assistantItem: AssistantConversationItem = { + message_id: messageIdFactory(), + session_id: input.sessionId, + role: "assistant", + text: input.assistantReply, + reply_type: input.replyType, + created_at: nowIso(), + trace_id: toTraceId(input.debug), + debug: input.debug as AssistantDebugPayload + }; + const logDetails = { + session_id: input.sessionId, + message_id: assistantItem.message_id, + user_message: input.userMessage, + living_router_mode: input.modeDecision?.mode ?? "chat", + living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected", + assistant_reply: assistantItem.text, + reply_type: assistantItem.reply_type, + trace_id: assistantItem.trace_id + }; + const commitResult = commitSafe({ + sessionId: input.sessionId, + assistantItem, + eventType: "assistant_message_chat", + logDetails, + appendItem: input.appendItem, + getSession: input.getSession, + persistSession: input.persistSession, + cloneConversation: input.cloneConversation, + logEvent: input.logEvent + }); + const response: AssistantMessageResponsePayload = { + ok: true, + session_id: input.sessionId, + assistant_reply: assistantItem.text, + reply_type: assistantItem.reply_type as AssistantReplyType, + conversation_item: assistantItem, + debug: input.debug as AssistantDebugPayload, + conversation: commitResult.conversation + }; + return { + assistantItem, + commitResult, + response + }; +} diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 6fbc0ce..a24712f 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -29,6 +29,7 @@ import * as assistantDeepTurnGroundingRuntimeAdapter_1 from "./assistantDeepTurn import * as assistantDeepTurnPackagingRuntimeAdapter_1 from "./assistantDeepTurnPackagingRuntimeAdapter"; import * as assistantDeepTurnPlanRuntimeAdapter_1 from "./assistantDeepTurnPlanRuntimeAdapter"; import * as assistantDeepTurnRetrievalRuntimeAdapter_1 from "./assistantDeepTurnRetrievalRuntimeAdapter"; +import * as assistantLivingChatTurnFinalizeRuntimeAdapter_1 from "./assistantLivingChatTurnFinalizeRuntimeAdapter"; import * as assistantQueryPlanning_1 from "./assistantQueryPlanning"; import iconv from "iconv-lite"; const DATA_SCOPE_CACHE_TTL_MS = 60_000; @@ -4559,49 +4560,21 @@ export class AssistantService { normalized: null, normalizer_output: null }; - const assistantItem = { - message_id: `msg-${(0, nanoid_1.nanoid)(10)}`, - session_id: sessionId, - role: "assistant", - text: chatText, - reply_type: "factual_with_explanation", - created_at: new Date().toISOString(), - trace_id: debug.trace_id, - debug - }; - this.sessions.appendItem(sessionId, assistantItem); - const current = this.sessions.getSession(sessionId); - if (current) { - this.sessionLogger.persistSession(current); - } - const conversation = cloneItems(current?.items ?? []); - (0, log_1.logJson)({ - timestamp: new Date().toISOString(), - level: "info", - service: "assistant_loop", - message: "assistant_message_processed", + const finalization = (0, assistantLivingChatTurnFinalizeRuntimeAdapter_1.finalizeAssistantLivingChatTurn)({ sessionId, - eventType: "assistant_message_chat", - details: { - session_id: sessionId, - message_id: assistantItem.message_id, - user_message: userMessage, - living_router_mode: modeDecision?.mode ?? "chat", - living_router_reason: modeDecision?.reason ?? "living_chat_signal_detected", - assistant_reply: assistantItem.text, - reply_type: assistantItem.reply_type, - trace_id: assistantItem.trace_id - } - }); - return { - ok: true, - session_id: sessionId, - assistant_reply: assistantItem.text, - reply_type: assistantItem.reply_type, - conversation_item: assistantItem, + userMessage, + assistantReply: chatText, + replyType: "factual_with_explanation", debug, - conversation - }; + modeDecision, + appendItem: (targetSessionId, item) => this.sessions.appendItem(targetSessionId, item), + getSession: (targetSessionId) => this.sessions.getSession(targetSessionId), + persistSession: (sessionState) => this.sessionLogger.persistSession(sessionState), + cloneConversation: (items) => cloneItems(items), + logEvent: (payload) => (0, log_1.logJson)(payload), + messageIdFactory: () => `msg-${(0, nanoid_1.nanoid)(10)}` + }); + return finalization.response; } catch (error) { (0, log_1.logJson)({ diff --git a/llm_normalizer/backend/tests/assistantLivingChatTurnFinalizeRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatTurnFinalizeRuntimeAdapter.test.ts new file mode 100644 index 0000000..d245dc2 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantLivingChatTurnFinalizeRuntimeAdapter.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { finalizeAssistantLivingChatTurn } from "../src/services/assistantLivingChatTurnFinalizeRuntimeAdapter"; + +describe("assistant living chat turn finalize runtime adapter", () => { + it("builds assistant chat item and emits expected living chat log envelope", () => { + const commitCalls: Array> = []; + const output = finalizeAssistantLivingChatTurn({ + sessionId: "asst-chat-1", + userMessage: "что ты умеешь?", + assistantReply: "Могу помочь с анализом данных 1С.", + replyType: "factual_with_explanation", + debug: { trace_id: "chat-trace-1", detected_mode: "chat" } as any, + modeDecision: { + mode: "chat", + reason: "assistant_capability_query_detected" + }, + appendItem: () => {}, + getSession: () => null, + persistSession: () => {}, + cloneConversation: () => [], + logEvent: () => {}, + messageIdFactory: () => "msg-chat-1", + nowIso: () => "2026-04-10T13:00:00.000Z", + commitFn: ((input: Record) => { + commitCalls.push(input); + return { + currentSession: null, + conversation: [] + }; + }) as any + }); + + expect(output.assistantItem.message_id).toBe("msg-chat-1"); + expect(output.assistantItem.created_at).toBe("2026-04-10T13:00:00.000Z"); + expect(output.assistantItem.trace_id).toBe("chat-trace-1"); + expect(commitCalls).toHaveLength(1); + expect(commitCalls[0]?.["eventType"]).toBe("assistant_message_chat"); + const logDetails = commitCalls[0]?.["logDetails"] as Record; + expect(logDetails?.["session_id"]).toBe("asst-chat-1"); + expect(logDetails?.["living_router_mode"]).toBe("chat"); + expect(logDetails?.["living_router_reason"]).toBe("assistant_capability_query_detected"); + expect(logDetails?.["assistant_reply"]).toBe("Могу помочь с анализом данных 1С."); + expect(output.response.reply_type).toBe("factual_with_explanation"); + }); + + it("uses default commit runtime and returns persisted conversation", () => { + let appendCalls = 0; + let persistCalls = 0; + let logCalls = 0; + let storedSession: any = null; + const output = finalizeAssistantLivingChatTurn({ + sessionId: "asst-chat-2", + userMessage: "какие есть данные?", + assistantReply: "Доступны данные по нескольким организациям.", + replyType: "factual_with_explanation", + debug: { trace_id: "chat-trace-2" } as any, + appendItem: (_sessionId, item) => { + appendCalls += 1; + storedSession = { + session_id: "asst-chat-2", + updated_at: "2026-04-10T13:00:00.000Z", + items: [item], + investigation_state: null + }; + }, + getSession: () => storedSession, + persistSession: () => { + persistCalls += 1; + }, + cloneConversation: (items) => items.map((item) => ({ ...item })), + logEvent: () => { + logCalls += 1; + }, + messageIdFactory: () => "msg-chat-2", + nowIso: () => "2026-04-10T13:05:00.000Z" + }); + + expect(appendCalls).toBe(1); + expect(persistCalls).toBe(1); + expect(logCalls).toBe(1); + expect(output.response.ok).toBe(true); + expect(output.response.session_id).toBe("asst-chat-2"); + expect(output.response.reply_type).toBe("factual_with_explanation"); + expect(output.response.conversation).toHaveLength(1); + expect(output.response.conversation_item.message_id).toBe("msg-chat-2"); + }); +});