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:
kappa
2026-01-29 11:02:27 +09:00
parent f304c6a7d4
commit fbe696b88c
8 changed files with 832 additions and 56 deletions

View File

@@ -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];

View File

@@ -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;

View File

@@ -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;
};
/**
* 알림 상세 정보

View File

@@ -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}`;

View File

@@ -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
View 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,
});
}