import path from "path"; import { ASSISTANT_SESSIONS_DIR } from "../config"; import type { AssistantConversationItem, AssistantReplyType, AssistantSessionState } from "../types/assistant"; import { ensureDir, writeJsonFile } from "../utils/files"; interface AssistantTurnLogRecord { turn_id: string; started_at: string | null; completed_at: string | null; human_block: string; human_readable: { question_raw: string; question_understood: string; decomposition: string[]; answer: string; reply_type: AssistantReplyType | null; }; technical_json: { trace_id: string | null; user_message: AssistantConversationItem; assistant_message: AssistantConversationItem; debug: AssistantConversationItem["debug"]; }; } interface AssistantSessionLogRecord { schema_version: "assistant_session_log_v1"; session_id: string; started_at: string; updated_at: string; counters: { total_messages: number; user_messages: number; assistant_messages: number; }; trace_ids: string[]; reply_types: AssistantReplyType[]; investigation_state: AssistantSessionState["investigation_state"]; address_navigation_state: AssistantSessionState["address_navigation_state"]; turns: AssistantTurnLogRecord[]; conversation: AssistantConversationItem[]; last_assistant: { message_id: string | null; reply_type: AssistantReplyType | null; trace_id: string | null; created_at: string | null; }; } function unique(values: Array): string[] { return Array.from(new Set(values.filter((item): item is string => typeof item === "string" && item.length > 0))); } function toObject(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } return value as Record; } function toStringOrNull(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed ? trimmed : null; } function extractFragments(assistantItem: AssistantConversationItem): Array> { if (!assistantItem.debug || !Array.isArray(assistantItem.debug.fragments)) { return []; } return assistantItem.debug.fragments .map((item) => toObject(item)) .filter((item): item is Record => item !== null); } function extractNormalizedQuestion(userText: string, assistantItem: AssistantConversationItem): string { const normalized = toObject(assistantItem.debug?.normalized); if (normalized) { const fromUserMessageRaw = toStringOrNull(normalized.user_message_raw); if (fromUserMessageRaw) return fromUserMessageRaw; const fromUserQuestionRaw = toStringOrNull(normalized.user_question_raw); if (fromUserQuestionRaw) return fromUserQuestionRaw; const fromNormalizedQuestion = toStringOrNull(normalized.normalized_question); if (fromNormalizedQuestion) return fromNormalizedQuestion; } const fragments = extractFragments(assistantItem); if (fragments.length > 0) { const joined = fragments .map((fragment) => toStringOrNull(fragment.normalized_fragment_text) ?? toStringOrNull(fragment.raw_fragment_text)) .filter((item): item is string => Boolean(item)) .join(" | "); if (joined) { return joined; } } return userText; } function buildRouteLookup(assistantItem: AssistantConversationItem): Map> { const output = new Map>(); if (!assistantItem.debug || !Array.isArray(assistantItem.debug.routes)) { return output; } for (const route of assistantItem.debug.routes) { const routeObject = toObject(route); if (!routeObject) continue; const fragmentId = toStringOrNull(routeObject.fragment_id); if (!fragmentId) continue; output.set(fragmentId, routeObject); } return output; } function buildDecompositionLines(assistantItem: AssistantConversationItem): string[] { const fragments = extractFragments(assistantItem); if (fragments.length === 0) { return ["Фрагменты декомпозиции не выделены."]; } const routeLookup = buildRouteLookup(assistantItem); return fragments.map((fragment, index) => { const fragmentId = toStringOrNull(fragment.fragment_id) ?? `F${index + 1}`; const fragmentText = toStringOrNull(fragment.normalized_fragment_text) ?? toStringOrNull(fragment.raw_fragment_text) ?? "текст фрагмента отсутствует"; const executionReadiness = toStringOrNull(fragment.execution_readiness); const routeStatus = toStringOrNull(fragment.route_status); const routeObject = routeLookup.get(fragmentId); const route = toStringOrNull(routeObject?.route); const noRouteReason = toStringOrNull(fragment.no_route_reason) ?? toStringOrNull(routeObject?.no_route_reason); const parts = [`${fragmentId}: ${fragmentText}`]; if (executionReadiness) parts.push(`execution_readiness=${executionReadiness}`); if (routeStatus) parts.push(`route_status=${routeStatus}`); if (route) parts.push(`route=${route}`); if (noRouteReason) parts.push(`no_route_reason=${noRouteReason}`); return parts.join("; "); }); } function toHumanBlock(input: { questionRaw: string; questionUnderstood: string; decomposition: string[]; answer: string; }): string { const lines: string[] = []; lines.push(`Вопрос: ${input.questionRaw}`); lines.push(`Понято как: ${input.questionUnderstood}`); lines.push("Декомпозиция:"); lines.push(...input.decomposition.map((item) => `- ${item}`)); lines.push(`Ответ: ${input.answer}`); return lines.join("\n"); } function buildTurns(items: AssistantConversationItem[]): AssistantTurnLogRecord[] { const turns: AssistantTurnLogRecord[] = []; const pendingUsers: AssistantConversationItem[] = []; for (const item of items) { if (item.role === "user") { pendingUsers.push(item); continue; } const pairedUser = pendingUsers.shift(); if (!pairedUser) { continue; } const questionRaw = pairedUser.text; const questionUnderstood = extractNormalizedQuestion(questionRaw, item); const decomposition = buildDecompositionLines(item); const answer = item.text; turns.push({ turn_id: `turn-${turns.length + 1}`, started_at: pairedUser.created_at ?? null, completed_at: item.created_at ?? null, human_block: toHumanBlock({ questionRaw, questionUnderstood, decomposition, answer }), human_readable: { question_raw: questionRaw, question_understood: questionUnderstood, decomposition, answer, reply_type: item.reply_type }, technical_json: { trace_id: item.trace_id, user_message: pairedUser, assistant_message: item, debug: item.debug } }); } return turns; } export class AssistantSessionLogger { constructor(private readonly rootDir: string = ASSISTANT_SESSIONS_DIR) {} public persistSession(session: AssistantSessionState): void { try { ensureDir(this.rootDir); const filePath = path.resolve(this.rootDir, `${session.session_id}.json`); const startedAt = session.items[0]?.created_at ?? session.updated_at; const userMessages = session.items.filter((item) => item.role === "user").length; const assistantMessages = session.items.filter((item) => item.role === "assistant").length; const assistantItems = session.items.filter((item) => item.role === "assistant"); const lastAssistant = assistantItems.length > 0 ? assistantItems[assistantItems.length - 1] : null; const traceIds = unique(session.items.map((item) => item.trace_id)); const replyTypes = Array.from( new Set( session.items .map((item) => item.reply_type) .filter((item): item is AssistantReplyType => typeof item === "string" && item.length > 0) ) ); const turns = buildTurns(session.items); const record: AssistantSessionLogRecord = { schema_version: "assistant_session_log_v1", session_id: session.session_id, started_at: startedAt, updated_at: session.updated_at, counters: { total_messages: session.items.length, user_messages: userMessages, assistant_messages: assistantMessages }, trace_ids: traceIds, reply_types: replyTypes, investigation_state: session.investigation_state, address_navigation_state: session.address_navigation_state, turns, conversation: session.items, last_assistant: { message_id: lastAssistant?.message_id ?? null, reply_type: lastAssistant?.reply_type ?? null, trace_id: lastAssistant?.trace_id ?? null, created_at: lastAssistant?.created_at ?? null } }; writeJsonFile(filePath, record); } catch (error) { const code = (error as { code?: unknown } | null)?.code; if (code === "ENOSPC") { return; } throw error; } } }