// src/main/services/HistoryService.ts // SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현. import { eq, desc, like, and, sql, count } from 'drizzle-orm' import { getDatabase } from '../db' import { history, stats } from '../db/schema' import type { History, NewHistory } from '../db/schema' import { getLogger } from './LoggerService' import type { HistoryEntry, HistoryQueryParams, HistoryPage, HistorySearchParams, StatsSummary } from '@d3ro/core/types' const logger = getLogger('HistoryService') class HistoryService { create(input: Omit): HistoryEntry { const db = getDatabase() const now = Date.now() const id = crypto.randomUUID() const entry: NewHistory = { id, ...input, createdAt: now, updatedAt: now } db.insert(history).values(entry).run() // stats 싱글톤 업데이트 this._updateStats(input.duration, input.wordCount) logger.info(`History entry created: ${id}`) // 비동기로 LLM 타이틀 자동 생성 (fire-and-forget) this.generateTitle(id).catch(() => { /* ignore */ }) return this._toEntry(entry as History) } getById(id: string): HistoryEntry | null { const db = getDatabase() const row = db.select().from(history).where(eq(history.id, id)).get() return row ? this._toEntry(row) : null } list(params: HistoryQueryParams): HistoryPage { const db = getDatabase() const { page, pageSize } = params const totalResult = db.select({ count: count() }).from(history).get() const total = totalResult?.count ?? 0 const entries = db .select() .from(history) .orderBy(desc(history.createdAt)) .limit(pageSize) .offset(page * pageSize) .all() return { entries: entries.map((e) => this._toEntry(e)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) } } search(params: HistorySearchParams): HistoryPage { const db = getDatabase() const { query, page, pageSize } = params const pattern = `%${query}%` const conditions = and( like(history.originalText, pattern) ) const totalResult = db .select({ count: count() }) .from(history) .where(conditions) .get() const total = totalResult?.count ?? 0 const entries = db .select() .from(history) .where(conditions) .orderBy(desc(history.createdAt)) .limit(pageSize) .offset(page * pageSize) .all() return { entries: entries.map((e) => this._toEntry(e)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) } } delete(id: string): boolean { const db = getDatabase() const result = db.delete(history).where(eq(history.id, id)).run() return result.changes > 0 } deleteAll(): void { const db = getDatabase() db.delete(history).run() logger.info('All history entries deleted') } getStats(): StatsSummary { const db = getDatabase() const row = db.select().from(stats).where(eq(stats.id, 1)).get() const todayStart = new Date() todayStart.setHours(0, 0, 0, 0) const todayMs = todayStart.getTime() const todayResult = db .select({ sessions: count(), duration: sql`COALESCE(SUM(duration), 0)`, words: sql`COALESCE(SUM(word_count), 0)` }) .from(history) .where( and( sql`created_at >= ${todayMs}`, eq(history.status, 'completed') ) ) .get() return { totalRecordingTimeMs: (row?.totalDuration ?? 0) * 1000, totalWordCount: row?.totalWords ?? 0, totalSessionCount: row?.sessionCount ?? 0, todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000, todayWordCount: todayResult?.words ?? 0, todaySessionCount: todayResult?.sessions ?? 0, streakDays: row?.streakDays ?? 0 } } /** * 보존 정책에 따라 오래된 항목 정리 (30일 초과) */ runRetentionCleanup(): number { const db = getDatabase() const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000 const result = db.delete(history).where(sql`created_at < ${cutoff}`).run() if (result.changes > 0) { logger.info(`Retention cleanup: ${result.changes} old entries removed`) } return result.changes } dispose(): void { logger.info('HistoryService disposed') } private _updateStats(duration: number, wordCount: number): void { const db = getDatabase() const now = Date.now() db.update(stats) .set({ totalDuration: sql`total_duration + ${duration}`, totalWords: sql`total_words + ${wordCount}`, sessionCount: sql`session_count + 1`, lastSessionAt: now, lastUpdated: now }) .where(eq(stats.id, 1)) .run() } /** 전사 완료 후 LLM으로 자동 타이틀 생성 */ async generateTitle(id: string): Promise { const entry = this.getById(id) if (!entry) return null const text = entry.polishedText || entry.originalText if (!text || text.length < 10) return null try { const { getLocalLLMService } = await import('./LocalLLMService') const llm = getLocalLLMService() const result = await llm.generate( text.slice(0, 2000), { systemPrompt: '다음 텍스트의 핵심 내용을 10단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.', temperature: 0.3, maxTokens: 50, }, ) const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80) if (title) { const db = getDatabase() db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run() logger.info(`Auto title generated: ${id} → "${title}"`) return title } } catch (err) { logger.warn(`Auto title generation failed: ${err instanceof Error ? err.message : String(err)}`) } return null } private _toEntry(row: History): HistoryEntry { return { id: row.id, title: row.title ?? null, originalText: row.originalText, polishedText: row.polishedText, focusedApp: row.focusedApp, focusedAppName: row.focusedAppName, focusedAppWindowTitle: row.focusedAppWindowTitle, mode: row.mode as HistoryEntry['mode'], status: row.status as HistoryEntry['status'], errorCode: row.errorCode, audioLocalPath: row.audioLocalPath, duration: row.duration, detectedLanguage: row.detectedLanguage, micDevice: row.micDevice, wordCount: row.wordCount, sttModel: row.sttModel, llmModel: row.llmModel, sttLatencyMs: row.sttLatencyMs, llmLatencyMs: row.llmLatencyMs, createdAt: row.createdAt, updatedAt: row.updatedAt, appVersion: row.appVersion, summaryText: row.summaryText ?? null, } } } let instance: HistoryService | null = null export function getHistoryService(): HistoryService { if (!instance) { instance = new HistoryService() } return instance }