import * as fs from 'fs'; import * as path from 'path'; import { getConfig } from '../config'; import { buildApiUrl, logError, logInfo, resolveEngine, summarizeText, _getBrainDir } from '../utils'; /** * IAIService: AI 모델 호출에 대한 인터페이스. * * `call(prompt)` 는 plain user 메시지 1개만 보내는 legacy shortcut이고, * `chat({ system, user })` 는 role-aware 호출이다. Telegram 핸들러처럼 * 모델을 grounding 해야 하는 경로에서는 system을 반드시 채워야 한다 — * gemma 같은 작은 모델은 system이 없으면 짧은/모호한 입력에 대해 * "시는 못 써드려요" 같은 환각 거절을 하는 경향이 있다. */ export interface IAIService { call(prompt: string): Promise; chat(req: AIChatRequest): Promise; } export interface AIChatRequest { /** Optional system prompt. Strongly recommended for short / ambiguous user inputs. */ system?: string; /** Required. The user message. */ user: string; /** Optional override (default = config.defaultModel). */ model?: string; /** Optional override (default = config.timeout). */ timeoutMs?: number; } export interface AIChatResult { content: string; /** Engine that actually returned the content. */ engine: 'lmstudio' | 'ollama'; model: string; /** True iff content came back empty after all retries. Caller decides UX. */ empty: boolean; } /** * IBrainService: 지식 베이스(Brain) 조작에 대한 인터페이스 */ export interface IBrainService { inject(title: string, markdown: string): Promise; } /** * AIService: Ollama 및 LM Studio 폴백 로직을 포함한 AI 호출 구현체. * * Behavior: * 1. Try the user-configured engine first; on transport / 5xx / empty response, * fall through to the other engine. * 2. Empty responses are treated as a soft failure: we log + retry the other * engine before giving up. Pure exceptions (network blip) trigger the same * fallback path. * 3. The legacy `call(prompt)` is preserved as a thin wrapper around `chat()` * for callers that don't have a system prompt — but new code should pass * a system prompt explicitly. */ export class AIService implements IAIService { public async call(prompt: string): Promise { const result = await this.chat({ user: prompt }); return result.content; } public async chat(req: AIChatRequest): Promise { const config = getConfig(); const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b'; const timeoutMs = req.timeoutMs ?? config.timeout; const primaryEngine = resolveEngine(config.ollamaUrl); const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const; const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = []; if (req.system && req.system.trim()) { messages.push({ role: 'system', content: req.system }); } messages.push({ role: 'user', content: req.user }); let lastError: Error | null = null; let lastEmptyEngine: typeof engines[number] | null = null; for (const engine of engines) { const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat'); const payload = { model, messages, stream: false, ...(engine === 'ollama' ? { options: { temperature: 0.7 } } : { temperature: 0.7 }), }; try { logInfo('[AIService] Request started.', { engine, apiUrl, model, hasSystem: !!req.system, userChars: req.user.length, }); const res = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs), }); const rawText = await res.text(); if (!res.ok) { lastError = new Error(`AI call failed: ${res.status} ${summarizeText(rawText, 250)}`); logError(`[AIService] ${engine} HTTP ${res.status}`, { body: summarizeText(rawText, 250) }); continue; } const data = rawText ? JSON.parse(rawText) as any : {}; const content = engine === 'lmstudio' ? (data.choices?.[0]?.message?.content || '') : (data.message?.content || data.response || ''); if (!content || !content.trim()) { // Treat empty as soft failure so the other engine gets a chance. lastEmptyEngine = engine; lastError = new Error(`AI engine '${engine}' returned an empty response.`); logError(`[AIService] ${engine} empty response — falling through.`, { model }); continue; } return { content, engine, model, empty: false }; } catch (error: any) { lastError = error instanceof Error ? error : new Error(String(error)); logError(`[AIService] ${engine} failed:`, lastError.message); } } // Both engines exhausted. Surface a result with empty=true so the // caller (e.g. Telegram handler) can produce a user-visible reply // instead of swallowing the failure. if (lastEmptyEngine) { return { content: '', engine: lastEmptyEngine, model, empty: true }; } throw lastError || new Error('All AI engines failed.'); } } /** * BrainService: 지식 베이스 파일 시스템 저장 및 관리 구현체 */ export class BrainService implements IBrainService { public async inject(title: string, markdown: string): Promise { const brainDir = _getBrainDir(); if (!fs.existsSync(brainDir)) { fs.mkdirSync(brainDir, { recursive: true }); } const today = new Date().toISOString().split('T')[0]; const datePath = path.join(brainDir, '00_Raw', today); if (!fs.existsSync(datePath)) { fs.mkdirSync(datePath, { recursive: true }); } const safeTitle = title.replace(/[^a-zA-Z0-9가-힣]/gi, '_'); const filePath = path.join(datePath, `${safeTitle}.md`); fs.writeFileSync(filePath, markdown, 'utf-8'); return filePath; } }