Files
telegram-bot-workers/src/tools/search-tool.ts
kappa 97d6aa2850 fix: critical P0+P1 issues for code quality score 9.0
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>
2026-01-19 23:59:18 +09:00

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