Files
gravl/backend/src/services/exaSearch.js
T
clawd f580fa81a6 feat(05-03): Implement API fallback handling for research display
- 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
2026-03-02 23:45:07 +01:00

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