f580fa81a6
- Enhanced exaSearch service with Exa API + fallback tier system * Tier 1: Exa API (primary) * Tier 2: Synthetic results with suggested web sources * Improved error handling with graceful degradation - Updated backend exerciseResearch route to return provider info * Returns 'provider' field identifying which API was used * Returns 'status' field (success/degraded) for UI feedback * Better error messages for debugging - Enhanced ResearchDisplay component with fallback feedback * New ResearchProviderBadge shows which provider was used * Visual indicators for fallback results (Suggested badge) * Support for multiple provider types (exa, fallback, gemini, etc.) * Improved error handling and recovery flows - Updated ExerciseResearchPanel with better error handling * Proper response parsing from backend * Forwards provider and status info to display component * Improved accessibility with tooltip hints - Added comprehensive Research Display styling * Responsive layout for mobile and desktop * Visual hierarchy for summaries and sources * Provider badge styling with color-coding * Fallback state indicators for user awareness
135 lines
4.2 KiB
JavaScript
135 lines
4.2 KiB
JavaScript
const DEFAULT_EXA_API_URL = 'https://api.exa.ai/search';
|
|
|
|
const buildSummary = (results) => {
|
|
if (!results || results.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const snippets = results
|
|
.map((result) => result.snippet || result.highlight)
|
|
.filter(Boolean);
|
|
|
|
if (snippets.length === 0) {
|
|
return results
|
|
.slice(0, 3)
|
|
.map((result) => result.title)
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
}
|
|
|
|
return snippets.slice(0, 3).join(' ');
|
|
};
|
|
|
|
/**
|
|
* Create synthetic results for fallback scenarios
|
|
* Generates plausible web search results when primary API is unavailable
|
|
*/
|
|
const createFallbackResults = (query, numResults = 5) => {
|
|
const sources = [
|
|
{ domain: 'wikipedia.org', title: `${query} - Wikipedia` },
|
|
{ domain: 'youtube.com', title: `${query} Tutorial | How to Perform Correctly` },
|
|
{ domain: 'fitnessforum.com', title: `Best Practices for ${query} Form and Technique` },
|
|
{ domain: 'acefitness.org', title: `Exercise Guide: ${query}` },
|
|
{ domain: 'stronglifts.com', title: `${query} Guide: Everything You Need to Know` },
|
|
{ domain: 'bodybuilding.com', title: `${query} Exercise - Benefits and Variations` },
|
|
{ domain: 'nhs.uk', title: `${query}: Health Benefits and Safety` },
|
|
{ domain: 'healthline.com', title: `${query}: Technique, Benefits & Common Mistakes` }
|
|
];
|
|
|
|
return sources.slice(0, numResults).map((source, index) => ({
|
|
id: `fallback-${index}`,
|
|
title: source.title,
|
|
url: `https://${source.domain}/search?q=${encodeURIComponent(query)}`,
|
|
snippet: `Learn about proper ${query} technique, benefits, and safety precautions.`,
|
|
publishedDate: new Date().toISOString(),
|
|
score: 0.8 - (index * 0.05),
|
|
isFallback: true,
|
|
provider: 'fallback'
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Main research search function with Exa API + fallback support
|
|
* Tier 1: Exa API (primary)
|
|
* Tier 2: Fallback to synthetic results with suggested sources
|
|
*/
|
|
const searchExerciseResearch = async ({ query, numResults = 5 }) => {
|
|
if (!query || typeof query !== 'string') {
|
|
throw new Error('Query must be a non-empty string');
|
|
}
|
|
|
|
const apiKey = process.env.EXA_API_KEY;
|
|
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
|
|
|
|
// Tier 1: Try Exa API (primary)
|
|
if (apiKey) {
|
|
try {
|
|
console.log(`📍 [Research] Attempting Exa API for: "${query}"`);
|
|
|
|
const response = await fetch(apiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-api-key': apiKey
|
|
},
|
|
body: JSON.stringify({
|
|
query,
|
|
numResults,
|
|
type: 'neural',
|
|
useAutoprompt: true
|
|
}),
|
|
timeout: 30000
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
console.warn(`⚠️ [Research] Exa API error: ${response.status}`);
|
|
throw new Error(`Exa search failed: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const results = (data.results || []).map((result) => ({
|
|
id: result.id,
|
|
title: result.title,
|
|
url: result.url,
|
|
snippet: Array.isArray(result.highlights) && result.highlights.length > 0
|
|
? result.highlights[0]
|
|
: result.snippet,
|
|
highlight: result.highlight,
|
|
publishedDate: result.publishedDate,
|
|
score: result.score,
|
|
provider: 'exa'
|
|
}));
|
|
|
|
console.log(`✅ [Research] Exa API success - ${results.length} results`);
|
|
|
|
return {
|
|
summary: buildSummary(results),
|
|
results,
|
|
provider: 'exa',
|
|
status: 'success'
|
|
};
|
|
} catch (err) {
|
|
console.warn(`⚠️ [Research] Exa API failed: ${err.message}`);
|
|
}
|
|
} else {
|
|
console.warn('⚠️ [Research] EXA_API_KEY not configured, using fallback');
|
|
}
|
|
|
|
// Tier 2: Fallback to synthetic results with suggested sources
|
|
console.log(`📍 [Research] Using fallback results for: "${query}"`);
|
|
const fallbackResults = createFallbackResults(query, numResults);
|
|
|
|
return {
|
|
summary: `Research sources for "${query}". Click links below to learn more about this exercise.`,
|
|
results: fallbackResults,
|
|
provider: 'fallback',
|
|
status: 'degraded'
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
searchExerciseResearch,
|
|
createFallbackResults
|
|
};
|