Phase 5-2 완료: 재시도 로직, 서킷 브레이커, 관리자 알림 생성된 파일: - src/utils/retry.ts (지수 백오프 재시도) - src/utils/circuit-breaker.ts (서킷 브레이커 패턴) - src/services/notification.ts (관리자 알림) - src/services/__test__/notification.test.ts (테스트 가이드) 수정된 파일: - src/openai-service.ts (Circuit Breaker + Retry 적용) - src/tools/search-tool.ts (4개 API 재시도) - src/tools/domain-tool.ts (11개 API 재시도) - CLAUDE.md (알림 시스템 문서 추가) 주요 기능: - 지수 백오프: 1초 → 2초 → 4초 (Jitter ±20%) - Circuit Breaker: 3회 실패 시 30초 차단 (OpenAI) - 재시도: 총 15개 외부 API 호출에 적용 - 알림: 3가지 유형 (Circuit Breaker, Retry, API Error) - Rate Limiting: 같은 알림 1시간 1회 검증: - ✅ TypeScript 컴파일 성공 - ✅ Wrangler 로컬 빌드 성공 - ✅ 프로덕션 배포 완료 (Version: c4a1a8e9)
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
import type { Env } from './types';
|
|
import { tools, selectToolsForMessage, executeTool } from './tools';
|
|
import { retryWithBackoff, RetryError } from './utils/retry';
|
|
import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker';
|
|
|
|
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
|
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
|
|
|
|
// Circuit Breaker 인스턴스 (전역 공유)
|
|
const openaiCircuitBreaker = new CircuitBreaker({
|
|
failureThreshold: 3, // 3회 연속 실패 시 차단
|
|
resetTimeoutMs: 30000, // 30초 후 복구 시도
|
|
monitoringWindowMs: 60000 // 1분 윈도우
|
|
});
|
|
|
|
interface OpenAIMessage {
|
|
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
content: string | null;
|
|
tool_calls?: ToolCall[];
|
|
tool_call_id?: string;
|
|
}
|
|
|
|
interface ToolCall {
|
|
id: string;
|
|
type: 'function';
|
|
function: {
|
|
name: string;
|
|
arguments: string;
|
|
};
|
|
}
|
|
|
|
interface OpenAIResponse {
|
|
choices: {
|
|
message: OpenAIMessage;
|
|
finish_reason: string;
|
|
}[];
|
|
}
|
|
|
|
// OpenAI API 호출 (retry + circuit breaker 적용)
|
|
async function callOpenAI(
|
|
apiKey: string,
|
|
messages: OpenAIMessage[],
|
|
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
|
): Promise<OpenAIResponse> {
|
|
return await retryWithBackoff(
|
|
async () => {
|
|
const response = await fetch(OPENAI_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-4o-mini',
|
|
messages,
|
|
tools: selectedTools?.length ? selectedTools : undefined,
|
|
tool_choice: selectedTools?.length ? 'auto' : undefined,
|
|
max_tokens: 1000,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
{
|
|
maxRetries: 3,
|
|
initialDelayMs: 1000,
|
|
maxDelayMs: 10000,
|
|
}
|
|
);
|
|
}
|
|
|
|
// 메인 응답 생성 함수
|
|
export async function generateOpenAIResponse(
|
|
env: Env,
|
|
userMessage: string,
|
|
systemPrompt: string,
|
|
recentContext: { role: 'user' | 'assistant'; content: string }[],
|
|
telegramUserId?: string,
|
|
db?: D1Database
|
|
): Promise<string> {
|
|
if (!env.OPENAI_API_KEY) {
|
|
throw new Error('OPENAI_API_KEY not configured');
|
|
}
|
|
|
|
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
|
|
|
|
try {
|
|
// Circuit Breaker로 전체 실행 감싸기
|
|
return await openaiCircuitBreaker.execute(async () => {
|
|
const messages: OpenAIMessage[] = [
|
|
{ role: 'system', content: systemPrompt },
|
|
...recentContext.map((m) => ({
|
|
role: m.role as 'user' | 'assistant',
|
|
content: m.content,
|
|
})),
|
|
{ role: 'user', content: userMessage },
|
|
];
|
|
|
|
// 동적 도구 선택
|
|
const selectedTools = selectToolsForMessage(userMessage);
|
|
|
|
// 첫 번째 호출
|
|
let response = await callOpenAI(apiKey, messages, selectedTools);
|
|
let assistantMessage = response.choices[0].message;
|
|
|
|
console.log('[OpenAI] tool_calls:', assistantMessage.tool_calls ? JSON.stringify(assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments }))) : 'none');
|
|
console.log('[OpenAI] content:', assistantMessage.content?.slice(0, 100));
|
|
|
|
// Function Calling 처리 (최대 3회 반복)
|
|
let iterations = 0;
|
|
while (assistantMessage.tool_calls && iterations < 3) {
|
|
iterations++;
|
|
|
|
// 도구 호출 결과 수집
|
|
const toolResults: OpenAIMessage[] = [];
|
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
const args = JSON.parse(toolCall.function.arguments);
|
|
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
|
|
|
|
// __KEYBOARD__ 마커가 있으면 AI 재해석 없이 바로 반환 (버튼 보존)
|
|
if (result.includes('__KEYBOARD__')) {
|
|
return result;
|
|
}
|
|
|
|
toolResults.push({
|
|
role: 'tool',
|
|
tool_call_id: toolCall.id,
|
|
content: result,
|
|
});
|
|
}
|
|
|
|
// 대화에 추가
|
|
messages.push({
|
|
role: 'assistant',
|
|
content: assistantMessage.content,
|
|
tool_calls: assistantMessage.tool_calls,
|
|
});
|
|
messages.push(...toolResults);
|
|
|
|
// 다시 호출 (도구 없이 응답 생성)
|
|
response = await callOpenAI(apiKey, messages, undefined);
|
|
assistantMessage = response.choices[0].message;
|
|
}
|
|
|
|
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
|
|
|
return finalResponse;
|
|
});
|
|
} catch (error) {
|
|
// 에러 처리
|
|
if (error instanceof CircuitBreakerError) {
|
|
console.error('[OpenAI] Circuit breaker open:', error.message);
|
|
return '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.';
|
|
}
|
|
|
|
if (error instanceof RetryError) {
|
|
console.error('[OpenAI] All retry attempts failed:', error.message);
|
|
return '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.';
|
|
}
|
|
|
|
// 기타 에러
|
|
console.error('[OpenAI] Unexpected error:', error);
|
|
return '죄송합니다. 예상치 못한 오류가 발생했습니다.';
|
|
}
|
|
}
|
|
|
|
// 프로필 생성용 (도구 없이)
|
|
export async function generateProfileWithOpenAI(
|
|
env: Env,
|
|
prompt: string
|
|
): Promise<string> {
|
|
if (!env.OPENAI_API_KEY) {
|
|
throw new Error('OPENAI_API_KEY not configured');
|
|
}
|
|
|
|
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
|
|
|
|
try {
|
|
// Circuit Breaker로 실행 감싸기
|
|
return await openaiCircuitBreaker.execute(async () => {
|
|
const response = await callOpenAI(
|
|
apiKey,
|
|
[{ role: 'user', content: prompt }],
|
|
undefined // 도구 없이 호출
|
|
);
|
|
|
|
return response.choices[0].message.content || '프로필 생성 실패';
|
|
});
|
|
} catch (error) {
|
|
// 에러 처리
|
|
if (error instanceof CircuitBreakerError) {
|
|
console.error('[OpenAI Profile] Circuit breaker open:', error.message);
|
|
return '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.';
|
|
}
|
|
|
|
if (error instanceof RetryError) {
|
|
console.error('[OpenAI Profile] All retry attempts failed:', error.message);
|
|
return '프로필 생성 실패: 재시도 횟수 초과';
|
|
}
|
|
|
|
// 기타 에러
|
|
console.error('[OpenAI Profile] Unexpected error:', error);
|
|
return '프로필 생성 실패: 예상치 못한 오류';
|
|
}
|
|
}
|