ded3eea7ce
주요 변경: [chunked writer 아키텍처 (v2.2.74~v2.2.75)] - 5-stage 다중 에이전트(planner/researcher/reflector/writer/synthesizer) 파이프라인 제거 → 단일 ChunkedWriter 의 outline → section[N] → polish 3-step 으로 교체. 본문 분석에서 추상화 손실 / 토큰 폭증 문제 해소 - 답변 길이 자동 분기: 짧은 prompt 는 fast-path direct 1회 호출, 본문 분석은 chunked. outline 빈 배열도 direct 폴백 [코드 리뷰 9개 항목 일괄 패치 (v2.2.76)] - /research polling hang 방어 (heartbeat + status 정규화 + 연속 실패 abort) - 회사 모드 dispatcher abort 신호를 AIService.chat 까지 전달 - bridgeFetch 에 onHeartbeat 콜백 도입 (slow endpoint 사용자 친화적) - dead code 정리: reflectionPersister.ts 제거 + enableReflection 등 좀비 config 키 - parseOutline 의 empty vs fallback reason 명시적 분리 - chatHandlers 의 회사 모드 케이스 ~325줄을 src/sidebar/companyHandlers.ts 로 분리 - Intent Alignment 라운드 한도 도달 시 smart 모드 자동 진행 - LM Studio doSwitch unload 실패 시 currentModel 정리 + load 강행 - retrieval informationDensity → queryCoverage 정합화 [/youtube 채널 지원 (v2.2.77~v2.2.82)] - 채널/플레이리스트 URL 자동 감지 + n:N 으로 영상 개수 지정 (최대 50) - 채널 루트 URL 에 /videos 탭 자동 append (yt-dlp enumeration 정상화) - 영상별 순차 처리 (queue 패턴) + i/N 진행 표시 + 마지막 통계 요약 - mode:info / mode:benchmark / mode:both 분석 모드 분기 - info: 영상 내용을 지식 카드로 추출 (튜토리얼·강의·뉴스용) - benchmark: 4-렌즈 대본 역기획서 (콘텐츠 제작 벤치마크용) - both: 둘 다 (기본) - bare keyword 도 허용: /youtube <url> n:1 info - bridge 에러 메시지 [object Object] 깨짐 수정 (구조화 에러 추출) - "패키지 없음" 등 환경 의존성 에러에 자동 가이드 첨부 [Astra: Setup Datacollect Dependencies 명령 추가 (v2.2.80)] - Python 자동 감지 + yt-dlp / youtube-transcript-api 자동 설치 - macOS PEP 668 환경 자동 폴백 (--user --break-system-packages) - /youtube 등에서 패키지 미설치 감지 시 "Install Now" 버튼 notification [테스트] - tests/agentEngine.test.ts 를 chunked flow 에 맞춰 전체 재작성 - tests/resilience_stress.test.ts Scenario B/D 를 role-aware mock 으로 갱신 - 399/399 통과 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
177 lines
7.3 KiB
TypeScript
177 lines
7.3 KiB
TypeScript
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<string>;
|
|
chat(req: AIChatRequest): Promise<AIChatResult>;
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* 외부 abort signal. fetch 가 받는 signal 과 OR 로 결합되어, 사용자가 회사 모드
|
|
* 도중 Stop 을 누르면 진행 중인 generation 이 즉시 중단된다. 없으면 timeout 만
|
|
* 적용. dispatcher 같은 긴 multi-turn 경로에서 반드시 전달할 것.
|
|
*/
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
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<string>;
|
|
}
|
|
|
|
/**
|
|
* 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<string> {
|
|
const result = await this.chat({ user: prompt });
|
|
return result.content;
|
|
}
|
|
|
|
public async chat(req: AIChatRequest): Promise<AIChatResult> {
|
|
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,
|
|
});
|
|
// timeout signal + 외부 abort signal 결합. 외부 signal 이 fire 되면
|
|
// 진행 중인 fetch 가 즉시 중단되어 사용자 Stop 이 LLM generation 중에도 효과.
|
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
const combinedSignal = req.signal
|
|
? AbortSignal.any([req.signal, timeoutSignal])
|
|
: timeoutSignal;
|
|
const res = await fetch(apiUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
signal: combinedSignal,
|
|
});
|
|
|
|
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<string> {
|
|
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;
|
|
}
|
|
}
|