diff --git a/app/api/web-search/route.ts b/app/api/web-search/route.ts index 53e6d2e0e..a19559fc3 100644 --- a/app/api/web-search/route.ts +++ b/app/api/web-search/route.ts @@ -2,12 +2,16 @@ * Web Search API * * POST /api/web-search - * Simple JSON request/response using Tavily search. + * Simple JSON request/response using Tavily or Claude search. */ import { NextRequest } from 'next/server'; import { callLLM } from '@/lib/ai/llm'; -import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { + searchWithTavily, + formatSearchResultsAsContext as formatTavilyContext, +} from '@/lib/web-search/tavily'; +import { searchWithClaude, formatClaudeSearchResultsAsContext } from '@/lib/web-search/claude'; import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { createLogger } from '@/lib/logger'; import { apiError, apiSuccess } from '@/lib/server/api-response'; @@ -17,6 +21,7 @@ import { } from '@/lib/server/search-query-builder'; import { resolveModelFromHeaders } from '@/lib/server/resolve-model'; import type { AICallFn } from '@/lib/generation/pipeline-types'; +import type { WebSearchProviderId } from '@/lib/web-search/types'; const log = createLogger('WebSearch'); @@ -28,10 +33,12 @@ export async function POST(req: NextRequest) { query: requestQuery, pdfText, apiKey: clientApiKey, + providerId = 'tavily', } = body as { query?: string; pdfText?: string; apiKey?: string; + providerId?: WebSearchProviderId; }; query = requestQuery; @@ -39,12 +46,12 @@ export async function POST(req: NextRequest) { return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required'); } - const apiKey = resolveWebSearchApiKey(clientApiKey); + const apiKey = resolveWebSearchApiKey(providerId, clientApiKey); if (!apiKey) { return apiError( 'MISSING_API_KEY', 400, - 'Tavily API key is not configured. Set it in Settings → Web Search or set TAVILY_API_KEY env var.', + `${providerId === 'claude' ? 'Anthropic' : 'Tavily'} API key is not configured. Set it in Settings → Web Search or set the appropriate env var.`, ); } @@ -75,14 +82,23 @@ export async function POST(req: NextRequest) { const searchQuery = await buildSearchQuery(query, boundedPdfText, aiCall); log.info('Running web search API request', { + provider: providerId, hasPdfContext: searchQuery.hasPdfContext, rawRequirementLength: searchQuery.rawRequirementLength, rewriteAttempted: searchQuery.rewriteAttempted, finalQueryLength: searchQuery.finalQueryLength, }); - const result = await searchWithTavily({ query: searchQuery.query, apiKey }); - const context = formatSearchResultsAsContext(result); + let result; + let context; + + if (providerId === 'claude') { + result = await searchWithClaude({ query: searchQuery.query, apiKey }); + context = formatClaudeSearchResultsAsContext(result); + } else { + result = await searchWithTavily({ query: searchQuery.query, apiKey }); + context = formatTavilyContext(result); + } return apiSuccess({ answer: result.answer, diff --git a/lib/server/classroom-generation.ts b/lib/server/classroom-generation.ts index a1ceabfb4..f95c12486 100644 --- a/lib/server/classroom-generation.ts +++ b/lib/server/classroom-generation.ts @@ -20,7 +20,12 @@ import { isProviderKeyRequired } from '@/lib/ai/providers'; import { resolveWebSearchApiKey } from '@/lib/server/provider-config'; import { resolveModel } from '@/lib/server/resolve-model'; import { buildSearchQuery } from '@/lib/server/search-query-builder'; -import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily'; +import { + searchWithTavily, + formatSearchResultsAsContext as formatTavilyContext, +} from '@/lib/web-search/tavily'; +import { searchWithClaude, formatClaudeSearchResultsAsContext } from '@/lib/web-search/claude'; +import type { WebSearchProviderId } from '@/lib/web-search/types'; import { persistClassroom } from '@/lib/server/classroom-storage'; import { generateMediaForClassroom, @@ -38,6 +43,7 @@ export interface GenerateClassroomInput { pdfContent?: { text: string; images: string[] }; language?: string; enableWebSearch?: boolean; + webSearchProviderId?: WebSearchProviderId; enableImageGeneration?: boolean; enableVideoGeneration?: boolean; enableTTS?: boolean; @@ -252,23 +258,36 @@ export async function generateClassroom( // Web search (optional, graceful degradation) let researchContext: string | undefined; if (input.enableWebSearch) { - const tavilyKey = resolveWebSearchApiKey(); - if (tavilyKey) { + const searchProvider = input.webSearchProviderId || 'tavily'; + const searchApiKey = resolveWebSearchApiKey(searchProvider); + + if (searchApiKey) { try { const searchQuery = await buildSearchQuery(requirement, pdfText, searchQueryAiCall); log.info('Running web search for classroom generation', { + provider: searchProvider, hasPdfContext: searchQuery.hasPdfContext, rawRequirementLength: searchQuery.rawRequirementLength, rewriteAttempted: searchQuery.rewriteAttempted, finalQueryLength: searchQuery.finalQueryLength, }); - const searchResult = await searchWithTavily({ - query: searchQuery.query, - apiKey: tavilyKey, - }); - researchContext = formatSearchResultsAsContext(searchResult); + let searchResult; + if (searchProvider === 'claude') { + searchResult = await searchWithClaude({ + query: searchQuery.query, + apiKey: searchApiKey, + }); + researchContext = formatClaudeSearchResultsAsContext(searchResult); + } else { + searchResult = await searchWithTavily({ + query: searchQuery.query, + apiKey: searchApiKey, + }); + researchContext = formatTavilyContext(searchResult); + } + if (researchContext) { log.info(`Web search returned ${searchResult.sources.length} sources`); } @@ -276,7 +295,9 @@ export async function generateClassroom( log.warn('Web search failed, continuing without search context:', e); } } else { - log.warn('enableWebSearch is true but no Tavily API key configured, skipping web search'); + log.warn( + `enableWebSearch is true but no ${searchProvider} API key configured, skipping web search`, + ); } } diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index 259208fde..4b8973acd 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -91,6 +91,7 @@ const VIDEO_ENV_MAP: Record = { const WEB_SEARCH_ENV_MAP: Record = { TAVILY: 'tavily', + ANTHROPIC: 'claude', }; // --------------------------------------------------------------------------- @@ -413,10 +414,13 @@ export function getServerWebSearchProviders(): Record server key > TAVILY_API_KEY env > empty */ -export function resolveWebSearchApiKey(clientKey?: string): string { +/** Resolve Web Search API key: client key > server key > ENV > empty */ +export function resolveWebSearchApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; - const serverKey = getConfig().webSearch.tavily?.apiKey; + const serverKey = getConfig().webSearch[providerId]?.apiKey; if (serverKey) return serverKey; - return process.env.TAVILY_API_KEY || ''; + + if (providerId === 'claude') return process.env.ANTHROPIC_API_KEY || ''; + if (providerId === 'tavily') return process.env.TAVILY_API_KEY || ''; + return ''; } diff --git a/lib/web-search/claude.ts b/lib/web-search/claude.ts new file mode 100644 index 000000000..378ba881c --- /dev/null +++ b/lib/web-search/claude.ts @@ -0,0 +1,134 @@ +/** + * Claude Web Search Integration + * + * Uses Anthropic's Messages API with the built-in server-side web search tool. + * Endpoint: POST https://api.anthropic.com/v1/messages + */ + +import { proxyFetch } from '@/lib/server/proxy-fetch'; +import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search'; + +const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'; + +/** + * Search the web using Claude's built-in web search tool. + * Note: The API automatically executes searches server-side and synthesizes + * an answer. We parse the message response to extract both. + */ +export async function searchWithClaude(params: { + query: string; + apiKey: string; + maxResults?: number; +}): Promise { + const { query, apiKey, maxResults = 5 } = params; + const startTime = Date.now(); + + const res = await proxyFetch(ANTHROPIC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-3-5-sonnet-latest', // Anthropic recommended model for web search capabilities + max_tokens: 1024, + messages: [ + { + role: 'user', + content: `Search the web for the following query and provide a detailed summary of the findings: ${query}`, + }, + ], + tools: [ + { + type: 'web_search_20250305', // Current stable server-side web search tool definition + name: 'web_search', + }, + ], + }), + }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + throw new Error(`Claude API error (${res.status}): ${errorText || res.statusText}`); + } + + const data = (await res.json()) as { + content?: Array<{ + type: string; + text?: string; + content?: Array<{ + type: string; + uri?: string; + url?: string; + title?: string; + cited_text?: string; + }>; + }>; + }; + + const responseTime = Date.now() - startTime; + + let answer = ''; + const sourcesMap = new Map(); + + // Parse Anthropic's multi-block message response + if (data.content && Array.isArray(data.content)) { + for (const block of data.content) { + if (block.type === 'text' && block.text) { + // Collect Claude's synthesized text response intent/answer + answer += block.text + '\n'; + } else if (block.type === 'web_search_tool_result' && Array.isArray(block.content)) { + // Extract URLs and titles from the server-executed search result blocks + for (const result of block.content) { + if (result.type === 'web_search_result') { + const url = result.url || result.uri; + if (url && !sourcesMap.has(url)) { + sourcesMap.set(url, { + title: result.title || 'Untitled', + url: url, + // Claude encrypts raw content snippets for security, so we extract cited text if available + content: result.cited_text || '', + score: 1, // Claude doesn't provide relevance scores + }); + } + } + } + } + } + } + + return { + answer: answer.trim(), + sources: Array.from(sourcesMap.values()).slice(0, maxResults), + query, + responseTime, + }; +} + +/** + * Format search results into a markdown context block for LLM prompts. + */ +export function formatClaudeSearchResultsAsContext(result: WebSearchResult): string { + if (!result.answer && result.sources.length === 0) { + return ''; + } + + const lines: string[] = []; + + if (result.answer) { + lines.push(result.answer); + lines.push(''); + } + + if (result.sources.length > 0) { + lines.push('Sources:'); + for (const src of result.sources) { + // Snippets aren't completely exposed by Anthropic by default, prioritize Title + URL + const contextStr = src.content ? `: ${src.content.slice(0, 200)}` : ''; + lines.push(`- [${src.title}](${src.url})${contextStr}`); + } + } + + return lines.join('\n'); +} diff --git a/lib/web-search/constants.ts b/lib/web-search/constants.ts index 6542bbb2a..cde2c9945 100644 --- a/lib/web-search/constants.ts +++ b/lib/web-search/constants.ts @@ -14,6 +14,12 @@ export const WEB_SEARCH_PROVIDERS: Record { it('returns client key first', async () => { const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); - expect(resolveWebSearchApiKey('client-key')).toBe('client-key'); + expect(resolveWebSearchApiKey('tavily', 'client-key')).toBe('client-key'); }); - it('falls back to TAVILY_API_KEY env var', async () => { + it('falls back to TAVILY_API_KEY env var for tavily provider', async () => { vi.stubEnv('TAVILY_API_KEY', 'tvly-bare-env'); const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); - expect(resolveWebSearchApiKey()).toBe('tvly-bare-env'); + expect(resolveWebSearchApiKey('tavily')).toBe('tvly-bare-env'); + }); + + it('falls back to ANTHROPIC_API_KEY env var for claude provider', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'claude-bare-env'); + const { resolveWebSearchApiKey } = await import('@/lib/server/provider-config'); + expect(resolveWebSearchApiKey('claude')).toBe('claude-bare-env'); }); });