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 { 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}. ${r.title}\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 { 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)}`; } }