P0 (Critical): - api.ts: Add transaction rollback on INSERT failure in /api/deposit/deduct - Restores balance if transaction record fails to insert - Logs rollback success/failure for audit trail - Maintains data consistency despite D1's non-transactional nature P1 (Important): - summary-service.ts: Replace double type assertions with Type Guards - Add D1BufferedMessageRow, D1SummaryRow interfaces - Add isBufferedMessageRow, isSummaryRow type guards - Runtime validation with compile-time type safety - Remove all `as unknown as` patterns - webhook.ts: Add integer range validation for callback queries - Add parseIntSafe() utility with min/max bounds - Validate domain registration price (0-10,000,000 KRW) - Prevent negative/overflow/NaN injection attacks - search-tool.ts: Implement KV caching for translation API - Cache Korean→English translations for 24 hours - Use RATE_LIMIT_KV namespace with 'translate:' prefix - Reduce redundant OpenAI API calls for repeated queries Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
212 lines
7.6 KiB
TypeScript
212 lines
7.6 KiB
TypeScript
import type {
|
|
Env,
|
|
OpenAIResponse,
|
|
BraveSearchResponse,
|
|
BraveSearchResult,
|
|
Context7SearchResponse,
|
|
Context7DocsResponse
|
|
} from '../types';
|
|
import { retryWithBackoff, RetryError } from '../utils/retry';
|
|
import { createLogger } from '../utils/logger';
|
|
import { getOpenAIUrl } from '../utils/api-urls';
|
|
|
|
const logger = createLogger('search-tool');
|
|
|
|
export const searchWebTool = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'search_web',
|
|
description: '웹에서 최신 정보를 검색합니다. 실시간 가격, 뉴스, 현재 날짜 이후 정보, 특정 사실 확인이 필요할 때 반드시 사용하세요. "비트코인 가격", "오늘 뉴스", "~란", "~뭐야" 등의 질문에 사용합니다.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: {
|
|
type: 'string',
|
|
description: '검색 쿼리',
|
|
},
|
|
},
|
|
required: ['query'],
|
|
},
|
|
},
|
|
};
|
|
|
|
export const lookupDocsTool = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'lookup_docs',
|
|
description: '프로그래밍 라이브러리의 공식 문서를 조회합니다. React, OpenAI, Cloudflare Workers 등의 최신 문서와 코드 예제를 검색할 수 있습니다.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
library: {
|
|
type: 'string',
|
|
description: '라이브러리 이름 (예: react, openai, cloudflare-workers, next.js)',
|
|
},
|
|
query: {
|
|
type: 'string',
|
|
description: '찾고 싶은 내용 (예: hooks 사용법, API 호출 방법)',
|
|
},
|
|
},
|
|
required: ['library', 'query'],
|
|
},
|
|
},
|
|
};
|
|
|
|
export async function executeSearchWeb(args: { query: string }, env?: Env): Promise<string> {
|
|
let query = args.query;
|
|
try {
|
|
if (!env?.BRAVE_API_KEY) {
|
|
return `🔍 검색 기능이 설정되지 않았습니다.`;
|
|
}
|
|
|
|
// 한글이 포함된 경우 영문으로 번역 (기술 용어, 제품명 등)
|
|
const hasKorean = /[가-힣]/.test(query);
|
|
let translatedQuery = query;
|
|
|
|
if (hasKorean && env?.OPENAI_API_KEY) {
|
|
try {
|
|
// 번역 캐시 키 생성
|
|
const cacheKey = `translate:${query}`;
|
|
|
|
// 1. 캐시 확인
|
|
let usedCache = false;
|
|
if (env.RATE_LIMIT_KV) {
|
|
const cached = await env.RATE_LIMIT_KV.get(cacheKey);
|
|
if (cached) {
|
|
translatedQuery = cached;
|
|
usedCache = true;
|
|
logger.info('캐시된 번역 사용', { original: query, translated: cached });
|
|
}
|
|
}
|
|
|
|
// 2. 번역 API 호출 (캐시 미스인 경우만)
|
|
if (!usedCache) {
|
|
const translateRes = await retryWithBackoff(
|
|
() => fetch(getOpenAIUrl(env), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-4o-mini',
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content: `사용자의 검색어를 영문으로 번역하세요.
|
|
- 외래어/기술용어는 원래 영문 표기로 변환 (예: 판골린→Pangolin, 도커→Docker)
|
|
- 일반 한국어는 영문으로 번역
|
|
- 검색에 최적화된 키워드로 변환
|
|
- 번역된 검색어만 출력, 설명 없이`
|
|
},
|
|
{ role: 'user', content: query }
|
|
],
|
|
max_tokens: 100,
|
|
temperature: 0.3,
|
|
}),
|
|
}),
|
|
{ maxRetries: 2 } // 번역은 중요하지 않으므로 재시도 2회로 제한
|
|
);
|
|
if (translateRes.ok) {
|
|
const translateData = await translateRes.json() as OpenAIResponse;
|
|
translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query;
|
|
logger.info('번역', { original: query, translated: translatedQuery });
|
|
|
|
// 3. 캐시 저장 (24시간 TTL)
|
|
if (env.RATE_LIMIT_KV && translatedQuery !== query) {
|
|
await env.RATE_LIMIT_KV.put(cacheKey, translatedQuery, { expirationTtl: 86400 });
|
|
logger.info('번역 캐싱', { query, translatedQuery });
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// 번역 실패 시 원본 사용 (RetryError 포함)
|
|
if (error instanceof RetryError) {
|
|
logger.info('번역 재시도 실패, 원본 사용', { message: error.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
const response = await retryWithBackoff(
|
|
() => fetch(
|
|
`${env.BRAVE_API_BASE || 'https://api.search.brave.com/res/v1'}/web/search?q=${encodeURIComponent(translatedQuery)}&count=5`,
|
|
{
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Subscription-Token': env.BRAVE_API_KEY!, // Already checked above
|
|
},
|
|
}
|
|
),
|
|
{ maxRetries: 3 }
|
|
);
|
|
if (!response.ok) {
|
|
return `🔍 검색 오류: ${response.status}`;
|
|
}
|
|
const data = await response.json() as BraveSearchResponse;
|
|
|
|
// Web 검색 결과 파싱
|
|
const webResults = data.web?.results || [];
|
|
if (webResults.length === 0) {
|
|
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}`
|
|
).join('\n\n');
|
|
|
|
// 번역된 경우 원본 쿼리도 표시
|
|
const queryDisplay = (hasKorean && translatedQuery !== query)
|
|
? `${query} (→ ${translatedQuery})`
|
|
: query;
|
|
|
|
return `🔍 검색 결과: ${queryDisplay}\n\n${results}`;
|
|
} catch (error) {
|
|
logger.error('오류', error as Error);
|
|
if (error instanceof RetryError) {
|
|
return `🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
|
}
|
|
return `검색 중 오류가 발생했습니다: ${String(error)}`;
|
|
}
|
|
}
|
|
|
|
export async function executeLookupDocs(args: { library: string; query: string }, env?: Env): Promise<string> {
|
|
const { library, query } = args;
|
|
try {
|
|
// Context7 REST API 직접 호출
|
|
// 1. 라이브러리 검색
|
|
const searchUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/libs/search?libraryName=${encodeURIComponent(library)}&query=${encodeURIComponent(query)}`;
|
|
const searchResponse = await retryWithBackoff(
|
|
() => fetch(searchUrl),
|
|
{ maxRetries: 3 }
|
|
);
|
|
const searchData = await searchResponse.json() as Context7SearchResponse;
|
|
|
|
if (!searchData.libraries?.length) {
|
|
return `📚 "${library}" 라이브러리를 찾을 수 없습니다.`;
|
|
}
|
|
|
|
const libraryId = searchData.libraries[0].id;
|
|
|
|
// 2. 문서 조회
|
|
const docsUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`;
|
|
const docsResponse = await retryWithBackoff(
|
|
() => fetch(docsUrl),
|
|
{ maxRetries: 3 }
|
|
);
|
|
const docsData = await docsResponse.json() as Context7DocsResponse;
|
|
|
|
if (docsData.error) {
|
|
return `📚 문서 조회 실패: ${docsData.message || docsData.error}`;
|
|
}
|
|
|
|
const content = docsData.context || docsData.content || JSON.stringify(docsData, null, 2);
|
|
return `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`;
|
|
} catch (error) {
|
|
logger.error('오류', error as Error);
|
|
if (error instanceof RetryError) {
|
|
return `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
|
}
|
|
return `📚 문서 조회 중 오류: ${String(error)}`;
|
|
}
|
|
}
|