refactor: complete P0-P1 improvements
Constants migration: - server-agent.ts: SERVER_CONSULTATION_STATUS, LANGUAGE_CODE - troubleshoot-agent.ts: TROUBLESHOOT_STATUS - notification.ts: NOTIFICATION_TYPE API improvements: - search-tool.ts: Zod schema validation for Brave/Context7 APIs - api-helper.ts: Centralized API call utility with retry/timeout Testing: - kv-cache.test.ts: 38 test cases for cache abstraction Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -139,6 +139,44 @@ export const DEPOSIT_ACTION = {
|
||||
REJECT: 'reject',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Notification types for admin alerts
|
||||
*/
|
||||
export const NOTIFICATION_TYPE = {
|
||||
CIRCUIT_BREAKER: 'circuit_breaker',
|
||||
RETRY_EXHAUSTED: 'retry_exhausted',
|
||||
API_ERROR: 'api_error',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Troubleshoot session statuses
|
||||
*/
|
||||
export const TROUBLESHOOT_STATUS = {
|
||||
GATHERING: 'gathering',
|
||||
DIAGNOSING: 'diagnosing',
|
||||
SOLVING: 'solving',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Server consultation session statuses
|
||||
*/
|
||||
export const SERVER_CONSULTATION_STATUS = {
|
||||
GATHERING: 'gathering',
|
||||
RECOMMENDING: 'recommending',
|
||||
SELECTING: 'selecting',
|
||||
COMPLETED: 'completed',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Language codes for multi-language support
|
||||
*/
|
||||
export const LANGUAGE_CODE = {
|
||||
KOREAN: 'ko',
|
||||
JAPANESE: 'ja',
|
||||
CHINESE: 'zh',
|
||||
ENGLISH: 'en',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Helper to create session key with user ID
|
||||
*
|
||||
@@ -183,3 +221,7 @@ export type ServerOrderStatus = typeof SERVER_ORDER_STATUS[keyof typeof SERVER_O
|
||||
export type ServerAction = typeof SERVER_ACTION[keyof typeof SERVER_ACTION];
|
||||
export type DomainAction = typeof DOMAIN_ACTION[keyof typeof DOMAIN_ACTION];
|
||||
export type DepositAction = typeof DEPOSIT_ACTION[keyof typeof DEPOSIT_ACTION];
|
||||
export type NotificationType = typeof NOTIFICATION_TYPE[keyof typeof NOTIFICATION_TYPE];
|
||||
export type TroubleshootStatus = typeof TROUBLESHOOT_STATUS[keyof typeof TROUBLESHOOT_STATUS];
|
||||
export type ServerConsultationStatus = typeof SERVER_CONSULTATION_STATUS[keyof typeof SERVER_CONSULTATION_STATUS];
|
||||
export type LanguageCode = typeof LANGUAGE_CODE[keyof typeof LANGUAGE_CODE];
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Env, ServerSession, BandwidthInfo, RecommendResponse } from './typ
|
||||
import { createLogger } from './utils/logger';
|
||||
import { executeSearchWeb, executeLookupDocs } from './tools/search-tool';
|
||||
import { formatTrafficInfo } from './utils/formatters';
|
||||
import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from './constants';
|
||||
|
||||
const logger = createLogger('server-agent');
|
||||
|
||||
@@ -683,7 +684,7 @@ export async function processServerConsultation(
|
||||
// 새 세션 생성하고 시작 메시지 반환
|
||||
const newSession: ServerSession = {
|
||||
telegramUserId: session.telegramUserId,
|
||||
status: 'gathering',
|
||||
status: SERVER_CONSULTATION_STATUS.GATHERING,
|
||||
collectedInfo: {},
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
@@ -699,10 +700,10 @@ export async function processServerConsultation(
|
||||
status: session.status,
|
||||
hasLastRecommendation: !!session.lastRecommendation,
|
||||
recommendationCount: session.lastRecommendation?.recommendations?.length || 0,
|
||||
willProcessSelection: session.status === 'selecting' && !!session.lastRecommendation
|
||||
willProcessSelection: session.status === SERVER_CONSULTATION_STATUS.SELECTING && !!session.lastRecommendation
|
||||
});
|
||||
|
||||
if (session.status === 'selecting' && session.lastRecommendation) {
|
||||
if (session.status === SERVER_CONSULTATION_STATUS.SELECTING && session.lastRecommendation) {
|
||||
// 상담과 무관한 키워드 감지 (selecting 상태에서만)
|
||||
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
|
||||
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/;
|
||||
@@ -812,7 +813,7 @@ export async function processServerConsultation(
|
||||
}
|
||||
|
||||
// Mark session as recommending
|
||||
session.status = 'recommending';
|
||||
session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING;
|
||||
await saveServerSession(env.DB, session.telegramUserId, session);
|
||||
|
||||
// 1. Call recommendation API (추천 먼저 받기)
|
||||
@@ -877,7 +878,7 @@ export async function processServerConsultation(
|
||||
use_case: session.collectedInfo.useCase || '웹 서비스',
|
||||
region_preference: finalRegionPreference,
|
||||
budget_limit: session.collectedInfo.budgetLimit,
|
||||
lang: 'ko',
|
||||
lang: LANGUAGE_CODE.KOREAN,
|
||||
},
|
||||
env
|
||||
);
|
||||
@@ -927,14 +928,14 @@ export async function processServerConsultation(
|
||||
use_case: session.collectedInfo.useCase || '웹 서비스',
|
||||
region_preference: session.collectedInfo.regionPreference,
|
||||
budget_limit: session.collectedInfo.budgetLimit,
|
||||
lang: 'ko',
|
||||
lang: LANGUAGE_CODE.KOREAN,
|
||||
},
|
||||
env,
|
||||
session.telegramUserId
|
||||
);
|
||||
|
||||
// Mark session as selecting (사용자 선택 대기)
|
||||
session.status = 'selecting';
|
||||
session.status = SERVER_CONSULTATION_STATUS.SELECTING;
|
||||
await saveServerSession(env.DB, session.telegramUserId, session);
|
||||
|
||||
// 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에)
|
||||
@@ -942,14 +943,14 @@ export async function processServerConsultation(
|
||||
return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`;
|
||||
} else {
|
||||
// 추천 결과 없음 - 세션 삭제
|
||||
session.status = 'completed';
|
||||
session.status = SERVER_CONSULTATION_STATUS.COMPLETED;
|
||||
await deleteServerSession(env.DB, session.telegramUserId);
|
||||
|
||||
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
|
||||
}
|
||||
} else {
|
||||
// Continue gathering information
|
||||
session.status = 'gathering';
|
||||
session.status = SERVER_CONSULTATION_STATUS.GATHERING;
|
||||
await saveServerSession(env.DB, session.telegramUserId, session);
|
||||
|
||||
return aiResult.message;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Env } from '../types';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { NOTIFICATION_TYPE } from '../constants';
|
||||
import type { NotificationType } from '../constants';
|
||||
|
||||
const logger = createLogger('notification');
|
||||
|
||||
/**
|
||||
* 알림 유형별 메시지 템플릿
|
||||
*/
|
||||
const NOTIFICATION_TEMPLATES = {
|
||||
circuit_breaker: (details: NotificationDetails) => `
|
||||
const NOTIFICATION_TEMPLATES: Record<NotificationType, (details: NotificationDetails) => string> = {
|
||||
[NOTIFICATION_TYPE.CIRCUIT_BREAKER]: (details: NotificationDetails) => `
|
||||
🚨 시스템 알림 (Circuit Breaker)
|
||||
|
||||
서비스: ${details.service}
|
||||
@@ -19,7 +21,7 @@ const NOTIFICATION_TEMPLATES = {
|
||||
${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
`.trim(),
|
||||
|
||||
retry_exhausted: (details: NotificationDetails) => `
|
||||
[NOTIFICATION_TYPE.RETRY_EXHAUSTED]: (details: NotificationDetails) => `
|
||||
⚠️ 시스템 알림 (재시도 실패)
|
||||
|
||||
서비스: ${details.service}
|
||||
@@ -31,7 +33,7 @@ ${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
`.trim(),
|
||||
|
||||
api_error: (details: NotificationDetails) => `
|
||||
[NOTIFICATION_TYPE.API_ERROR]: (details: NotificationDetails) => `
|
||||
🔴 시스템 알림 (API 에러)
|
||||
|
||||
서비스: ${details.service}
|
||||
@@ -42,12 +44,7 @@ ${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
즉시 확인이 필요합니다.
|
||||
${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
`.trim(),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 알림 유형
|
||||
*/
|
||||
export type NotificationType = keyof typeof NOTIFICATION_TEMPLATES;
|
||||
};
|
||||
|
||||
/**
|
||||
* 알림 상세 정보
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type {
|
||||
Env,
|
||||
OpenAIResponse,
|
||||
BraveSearchResponse,
|
||||
BraveSearchResult,
|
||||
Context7SearchResponse,
|
||||
Context7DocsResponse
|
||||
OpenAIResponse
|
||||
} from '../types';
|
||||
import { z } from 'zod';
|
||||
import { retryWithBackoff, RetryError } from '../utils/retry';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { getOpenAIUrl } from '../utils/api-urls';
|
||||
@@ -13,6 +10,39 @@ import { ERROR_MESSAGES } from '../constants/messages';
|
||||
|
||||
const logger = createLogger('search-tool');
|
||||
|
||||
// Zod schemas for API response validation
|
||||
const BraveSearchResultSchema = z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const BraveSearchResponseSchema = z.object({
|
||||
web: z.object({
|
||||
results: z.array(BraveSearchResultSchema),
|
||||
}).optional(),
|
||||
query: z.object({
|
||||
original: z.string(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const Context7LibrarySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
const Context7SearchResponseSchema = z.object({
|
||||
libraries: z.array(Context7LibrarySchema).optional(),
|
||||
results: z.array(Context7LibrarySchema).optional(), // API가 results로 반환
|
||||
});
|
||||
|
||||
const Context7DocsResponseSchema = z.object({
|
||||
context: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const searchWebTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -143,7 +173,20 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
if (!response.ok) {
|
||||
return `🔍 검색 오류: ${response.status}`;
|
||||
}
|
||||
const data = await response.json() as BraveSearchResponse;
|
||||
|
||||
// Zod를 사용한 응답 검증
|
||||
const json = await response.json();
|
||||
const parsed = BraveSearchResponseSchema.safeParse(json);
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.warn('Brave API 응답 파싱 실패', {
|
||||
error: parsed.error.issues,
|
||||
receivedData: JSON.stringify(json).substring(0, 200)
|
||||
});
|
||||
return `🔍 검색 결과 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
// Web 검색 결과 파싱
|
||||
const webResults = data.web?.results || [];
|
||||
@@ -151,8 +194,8 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
return `🔍 "${query}"에 대한 검색 결과가 없습니다.`;
|
||||
}
|
||||
|
||||
const results = webResults.slice(0, 3).map((r: BraveSearchResult, i: number) =>
|
||||
`${i + 1}. <b>${r.title}</b>\n ${r.description}\n ${r.url}`
|
||||
const results = webResults.slice(0, 3).map((r, i: number) =>
|
||||
`${i + 1}. <b>${r.title}</b>\n ${r.description || '설명 없음'}\n ${r.url}`
|
||||
).join('\n\n');
|
||||
|
||||
// 번역된 경우 원본 쿼리도 표시
|
||||
@@ -192,13 +235,27 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
() => fetch(searchUrl),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
const searchData = await searchResponse.json() as Context7SearchResponse;
|
||||
|
||||
if (!searchData.libraries?.length) {
|
||||
// Zod를 사용한 검색 응답 검증
|
||||
const searchJson = await searchResponse.json();
|
||||
const searchParsed = Context7SearchResponseSchema.safeParse(searchJson);
|
||||
|
||||
if (!searchParsed.success) {
|
||||
logger.warn('Context7 검색 API 응답 파싱 실패', {
|
||||
error: searchParsed.error.issues,
|
||||
receivedData: JSON.stringify(searchJson).substring(0, 200)
|
||||
});
|
||||
return `📚 라이브러리 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
const searchData = searchParsed.data;
|
||||
const libraries = searchData.libraries || searchData.results || [];
|
||||
|
||||
if (libraries.length === 0) {
|
||||
return `📚 "${library}" 라이브러리를 찾을 수 없습니다.`;
|
||||
}
|
||||
|
||||
const libraryId = searchData.libraries[0].id;
|
||||
const libraryId = libraries[0].id;
|
||||
|
||||
// 3. 문서 조회
|
||||
const docsUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`;
|
||||
@@ -206,7 +263,20 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
() => fetch(docsUrl),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
const docsData = await docsResponse.json() as Context7DocsResponse;
|
||||
|
||||
// Zod를 사용한 문서 응답 검증
|
||||
const docsJson = await docsResponse.json();
|
||||
const docsParsed = Context7DocsResponseSchema.safeParse(docsJson);
|
||||
|
||||
if (!docsParsed.success) {
|
||||
logger.warn('Context7 문서 API 응답 파싱 실패', {
|
||||
error: docsParsed.error.issues,
|
||||
receivedData: JSON.stringify(docsJson).substring(0, 200)
|
||||
});
|
||||
return `📚 문서 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
const docsData = docsParsed.data;
|
||||
|
||||
if (docsData.error) {
|
||||
return `📚 문서 조회 실패: ${docsData.message || docsData.error}`;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { Env, TroubleshootSession } from './types';
|
||||
import { createLogger } from './utils/logger';
|
||||
import { executeSearchWeb, executeLookupDocs } from './tools/search-tool';
|
||||
import { TROUBLESHOOT_STATUS } from './constants';
|
||||
|
||||
const logger = createLogger('troubleshoot-agent');
|
||||
|
||||
@@ -435,11 +436,11 @@ export async function processTroubleshoot(
|
||||
|
||||
// Update session status based on action
|
||||
if (aiResult.action === 'diagnose') {
|
||||
session.status = 'diagnosing';
|
||||
session.status = TROUBLESHOOT_STATUS.DIAGNOSING;
|
||||
} else if (aiResult.action === 'solve') {
|
||||
session.status = 'solving';
|
||||
session.status = TROUBLESHOOT_STATUS.SOLVING;
|
||||
} else {
|
||||
session.status = 'gathering';
|
||||
session.status = TROUBLESHOOT_STATUS.GATHERING;
|
||||
}
|
||||
|
||||
await saveTroubleshootSession(env.SESSION_KV, session.telegramUserId, session);
|
||||
|
||||
157
src/utils/api-helper.ts
Normal file
157
src/utils/api-helper.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* API 호출 추상화 유틸리티
|
||||
*
|
||||
* Cloudflare Workers 환경에서 외부 API 호출을 위한 추상화 레이어입니다.
|
||||
* 재시도 로직, 타임아웃, 응답 검증을 자동으로 처리합니다.
|
||||
*
|
||||
* @module api-helper
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { callApi } from './utils/api-helper';
|
||||
* import { z } from 'zod';
|
||||
*
|
||||
* // 스키마 정의
|
||||
* const UserSchema = z.object({
|
||||
* id: z.number(),
|
||||
* name: z.string(),
|
||||
* });
|
||||
*
|
||||
* // API 호출 (자동 검증)
|
||||
* const user = await callApi('https://api.example.com/user', {
|
||||
* schema: UserSchema,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { createLogger } from './logger';
|
||||
import { retryWithBackoff } from './retry';
|
||||
|
||||
const logger = createLogger('api-helper');
|
||||
|
||||
/**
|
||||
* API 호출 옵션 인터페이스
|
||||
*
|
||||
* @template T - 응답 타입
|
||||
*/
|
||||
export interface ApiOptions<T> {
|
||||
/** HTTP 메서드 (기본값: 'GET') */
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
/** HTTP 헤더 (기본 헤더와 병합됨) */
|
||||
headers?: Record<string, string>;
|
||||
/** 요청 본문 (JSON으로 자동 직렬화) */
|
||||
body?: Record<string, unknown>;
|
||||
/** 타임아웃 (밀리초, 기본값: 30000) */
|
||||
timeout?: number;
|
||||
/** 재시도 횟수 (기본값: 3) */
|
||||
retries?: number;
|
||||
/** 응답 검증 스키마 (Zod) */
|
||||
schema?: z.ZodType<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 호출 추상화 함수
|
||||
*
|
||||
* 다음 기능을 자동으로 처리합니다:
|
||||
* - 재시도 로직 (지수 백오프 + 지터)
|
||||
* - 타임아웃 제어
|
||||
* - JSON 응답 파싱
|
||||
* - 응답 검증 (Zod 스키마)
|
||||
* - 구조화된 로깅
|
||||
*
|
||||
* @template T - 응답 타입
|
||||
* @param url - 호출할 API URL
|
||||
* @param options - API 호출 옵션
|
||||
* @returns 파싱 및 검증된 응답 데이터
|
||||
* @throws Error if API call fails after retries
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 기본 GET 요청
|
||||
* const data = await callApi<{ status: string }>('https://api.example.com/health');
|
||||
*
|
||||
* // POST 요청 with body
|
||||
* const result = await callApi('https://api.example.com/data', {
|
||||
* method: 'POST',
|
||||
* body: { name: 'test' },
|
||||
* });
|
||||
*
|
||||
* // 스키마 검증
|
||||
* const ResponseSchema = z.object({
|
||||
* success: z.boolean(),
|
||||
* data: z.array(z.string()),
|
||||
* });
|
||||
*
|
||||
* const validated = await callApi('https://api.example.com/list', {
|
||||
* schema: ResponseSchema,
|
||||
* });
|
||||
*
|
||||
* // 커스텀 타임아웃 및 재시도
|
||||
* const important = await callApi('https://api.example.com/critical', {
|
||||
* timeout: 60000, // 60초
|
||||
* retries: 5, // 5회 재시도
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function callApi<T>(
|
||||
url: string,
|
||||
options?: ApiOptions<T>
|
||||
): Promise<T> {
|
||||
return retryWithBackoff(async () => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options?.timeout || 30000);
|
||||
|
||||
try {
|
||||
logger.debug('API 호출 시작', {
|
||||
url,
|
||||
method: options?.method || 'GET',
|
||||
timeout: options?.timeout || 30000,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options?.method || 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('API 호출 실패', undefined, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (options?.schema) {
|
||||
try {
|
||||
const validated = options.schema.parse(json);
|
||||
logger.debug('API 응답 검증 성공', { url });
|
||||
return validated;
|
||||
} catch (error) {
|
||||
logger.error('API 응답 검증 실패', error as Error, {
|
||||
url,
|
||||
response: json,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('API 호출 성공', { url });
|
||||
return json as T;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}, {
|
||||
maxRetries: options?.retries || 3,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user