Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions app/api/web-search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');

Expand All @@ -28,23 +33,25 @@ 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;

if (!query || !query.trim()) {
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.`,
);
}

Expand Down Expand Up @@ -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,
Expand Down
39 changes: 30 additions & 9 deletions lib/server/classroom-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +43,7 @@ export interface GenerateClassroomInput {
pdfContent?: { text: string; images: string[] };
language?: string;
enableWebSearch?: boolean;
webSearchProviderId?: WebSearchProviderId;
enableImageGeneration?: boolean;
enableVideoGeneration?: boolean;
enableTTS?: boolean;
Expand Down Expand Up @@ -252,31 +258,46 @@ 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`);
}
} catch (e) {
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`,
);
}
}

Expand Down
12 changes: 8 additions & 4 deletions lib/server/provider-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const VIDEO_ENV_MAP: Record<string, string> = {

const WEB_SEARCH_ENV_MAP: Record<string, string> = {
TAVILY: 'tavily',
ANTHROPIC: 'claude',
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -413,10 +414,13 @@ export function getServerWebSearchProviders(): Record<string, { baseUrl?: string
return result;
}

/** Resolve Tavily API key: client key > 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 '';
}
134 changes: 134 additions & 0 deletions lib/web-search/claude.ts
Original file line number Diff line number Diff line change
@@ -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<WebSearchResult> {
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<string, WebSearchSource>();

// 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');
}
6 changes: 6 additions & 0 deletions lib/web-search/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export const WEB_SEARCH_PROVIDERS: Record<WebSearchProviderId, WebSearchProvider
requiresApiKey: true,
defaultBaseUrl: 'https://api.tavily.com',
},
claude: {
id: 'claude',
name: 'Claude Web Search',
requiresApiKey: true,
defaultBaseUrl: 'https://api.anthropic.com',
},
};

/**
Expand Down
2 changes: 1 addition & 1 deletion lib/web-search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/**
* Web Search Provider IDs
*/
export type WebSearchProviderId = 'tavily';
export type WebSearchProviderId = 'tavily' | 'claude';

/**
* Web Search Provider Configuration
Expand Down
Loading
Loading