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
This commit is contained in:
2026-03-02 23:45:07 +01:00
parent 2a0496b915
commit f580fa81a6
5 changed files with 444 additions and 167 deletions
+10 -4
View File
@@ -45,7 +45,8 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
? Math.min(requestedResults, 10) ? Math.min(requestedResults, 10)
: 5; : 5;
const { summary, results } = await exaSearch({ query, numResults }); // Fetch research with fallback support
const { summary, results, provider, status } = await exaSearch({ query, numResults });
let researchRecord = null; let researchRecord = null;
try { try {
@@ -53,7 +54,7 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
`INSERT INTO research_results (exercise_id, query, summary, results, provider) `INSERT INTO research_results (exercise_id, query, summary, results, provider)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`, RETURNING id, created_at`,
[exerciseId, query, summary, JSON.stringify(results), 'exa'] [exerciseId, query, summary, JSON.stringify(results), provider || 'exa']
); );
researchRecord = insertResult.rows[0] || null; researchRecord = insertResult.rows[0] || null;
} catch (err) { } catch (err) {
@@ -65,11 +66,16 @@ const createExerciseResearchRouter = ({ pool, exaSearch }) => {
query, query,
summary, summary,
results, results,
stored: researchRecord stored: researchRecord,
provider: provider || 'exa',
status: status || 'success'
}); });
} catch (err) { } catch (err) {
console.error('Error running exercise research:', err); console.error('Error running exercise research:', err);
res.status(500).json({ error: 'Failed to fetch research' }); res.status(500).json({
error: 'Failed to fetch research',
message: err.message
});
} }
}); });
+58 -51
View File
@@ -1,5 +1,3 @@
const { generateWithFallback } = require('../utils/gemini-fallback');
const DEFAULT_EXA_API_URL = 'https://api.exa.ai/search'; const DEFAULT_EXA_API_URL = 'https://api.exa.ai/search';
const buildSummary = (results) => { const buildSummary = (results) => {
@@ -22,57 +20,52 @@ const buildSummary = (results) => {
return snippets.slice(0, 3).join(' '); return snippets.slice(0, 3).join(' ');
}; };
const generateFallbackSummary = async (query, numResults) => { /**
try { * Create synthetic results for fallback scenarios
console.log('🔄 Fallback: Generating AI summary for query:', query); * Generates plausible web search results when primary API is unavailable
const prompt = `Provide a concise summary of exercise form and technique for: "${query}". Include key points about proper form, common mistakes, and benefits. Keep it to 2-3 sentences.`; */
const createFallbackResults = (query, numResults = 5) => {
const response = await generateWithFallback(prompt, { const sources = [
temperature: 0.7, { domain: 'wikipedia.org', title: `${query} - Wikipedia` },
maxTokens: 256 { 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` }
];
if (response.success && response.data) { return sources.slice(0, numResults).map((source, index) => ({
console.log(`✅ AI fallback summary generated (provider: ${response.provider})`); id: `fallback-${index}`,
// Extract text from the appropriate response format title: source.title,
let summaryText = ''; url: `https://${source.domain}/search?q=${encodeURIComponent(query)}`,
if (response.data.response) { snippet: `Learn about proper ${query} technique, benefits, and safety precautions.`,
summaryText = response.data.response; publishedDate: new Date().toISOString(),
} else if (response.data.choices?.[0]?.message?.content) { score: 0.8 - (index * 0.05),
summaryText = response.data.choices[0].message.content; isFallback: true,
} provider: 'fallback'
}));
return {
summary: summaryText,
results: [], // No source links from AI fallback
provider: response.provider
};
}
} catch (err) {
console.error('AI fallback summary generation failed:', err.message);
}
// Last resort: simple template
return {
summary: `Exercise information for ${query}. Consult a fitness professional for proper form and safety guidelines.`,
results: [],
provider: 'template'
};
}; };
/**
* 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 }) => { const searchExerciseResearch = async ({ query, numResults = 5 }) => {
if (!query || typeof query !== 'string') { if (!query || typeof query !== 'string') {
throw new Error('Query must be a non-empty string'); throw new Error('Query must be a non-empty string');
} }
const apiKey = process.env.EXA_API_KEY; const apiKey = process.env.EXA_API_KEY;
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
// Tier 1: Try Exa API if configured
// Tier 1: Try Exa API (primary)
if (apiKey) { if (apiKey) {
try { try {
console.log('📍 Tier 1: Attempting Exa search API...'); console.log(`📍 [Research] Attempting Exa API for: "${query}"`);
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -84,10 +77,13 @@ const searchExerciseResearch = async ({ query, numResults = 5 }) => {
numResults, numResults,
type: 'neural', type: 'neural',
useAutoprompt: true useAutoprompt: true
}) }),
timeout: 30000
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text();
console.warn(`⚠️ [Research] Exa API error: ${response.status}`);
throw new Error(`Exa search failed: ${response.status}`); throw new Error(`Exa search failed: ${response.status}`);
} }
@@ -101,27 +97,38 @@ const searchExerciseResearch = async ({ query, numResults = 5 }) => {
: result.snippet, : result.snippet,
highlight: result.highlight, highlight: result.highlight,
publishedDate: result.publishedDate, publishedDate: result.publishedDate,
score: result.score score: result.score,
provider: 'exa'
})); }));
console.log('✅ Exa API success'); console.log(`✅ [Research] Exa API success - ${results.length} results`);
return { return {
summary: buildSummary(results), summary: buildSummary(results),
results, results,
provider: 'exa' provider: 'exa',
status: 'success'
}; };
} catch (err) { } catch (err) {
console.warn(`⚠️ Exa API failed: ${err.message}, falling back to AI...`); console.warn(`⚠️ [Research] Exa API failed: ${err.message}`);
} }
} else { } else {
console.warn('⚠️ EXA_API_KEY not configured, using AI fallback...'); console.warn('⚠️ [Research] EXA_API_KEY not configured, using fallback');
} }
// Tier 2: Fallback to AI-generated summary // Tier 2: Fallback to synthetic results with suggested sources
return generateFallbackSummary(query, numResults); 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 = { module.exports = {
searchExerciseResearch, searchExerciseResearch,
generateFallbackSummary createFallbackResults
}; };
+299
View File
@@ -3168,3 +3168,302 @@
.modal-btn.confirm:active:not(:disabled) { .modal-btn.confirm:active:not(:disabled) {
transform: scale(0.98); transform: scale(0.98);
} }
/* ============================================
RESEARCH DISPLAY COMPONENT
============================================ */
.research-panel {
margin: var(--space-4) 0;
padding: var(--space-4);
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
}
.research-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
gap: var(--space-3);
}
.research-panel-title {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.research-btn {
padding: 8px 16px;
font-size: var(--font-sm);
white-space: nowrap;
}
/* Loading State */
.rd-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-6);
color: var(--text-secondary);
}
.rd-spinner {
width: 32px;
height: 32px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.rd-loading-text {
font-size: var(--font-sm);
text-align: center;
}
.rd-loading-text em {
color: var(--accent);
font-style: normal;
font-weight: 600;
}
/* Error State */
.rd-error {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
background: rgba(255, 107, 74, 0.1);
border: 1px solid rgba(255, 107, 74, 0.3);
border-radius: var(--radius-md);
color: #ff6b4a;
font-size: var(--font-sm);
}
.rd-error-icon {
flex-shrink: 0;
font-size: var(--font-lg);
}
.rd-error-message {
flex: 1;
}
.rd-dismiss {
flex-shrink: 0;
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: var(--font-lg);
padding: 0;
opacity: 0.7;
transition: opacity var(--transition-base);
}
.rd-dismiss:hover {
opacity: 1;
}
/* Results Container */
.rd-results {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.rd-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
}
.rd-header-content {
flex: 1;
}
/* Summary Section */
.rd-summary {
margin-bottom: var(--space-4);
}
.rd-section-title {
font-size: var(--font-md);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-2) 0;
}
.rd-section-icon {
font-size: var(--font-lg);
}
.rd-count {
margin-left: auto;
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 12px;
}
.rd-summary-text {
color: var(--text-secondary);
font-size: var(--font-sm);
line-height: 1.6;
margin: 0;
}
/* Sources List */
.rd-sources {
margin-top: var(--space-4);
}
.rd-sources-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.rd-source-item {
padding: var(--space-3);
background: var(--bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--border);
transition: all var(--transition-base);
}
.rd-source-item:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.1);
}
.rd-source-link {
display: flex;
align-items: flex-start;
gap: var(--space-2);
color: var(--accent);
text-decoration: none;
font-size: var(--font-sm);
font-weight: 500;
transition: color var(--transition-base);
}
.rd-source-link:hover {
color: #ff8066;
}
.rd-source-index {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--accent);
color: white;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
}
.rd-source-title {
flex: 1;
word-break: break-word;
}
.rd-source-arrow {
flex-shrink: 0;
opacity: 0.6;
}
.rd-source-snippet {
margin: var(--space-2) 0 0 0;
padding: 0 0 0 32px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
}
.rd-source-badge {
display: inline-block;
margin-top: var(--space-2);
padding: 4px 8px;
background: rgba(255, 107, 74, 0.15);
color: var(--accent);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Empty State */
.rd-empty {
padding: var(--space-4);
text-align: center;
color: var(--text-secondary);
font-size: var(--font-sm);
margin: 0;
}
/* Provider Badge */
.rd-provider-badge {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 8px 12px;
border-radius: var(--radius-md);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.rd-provider-primary {
background: rgba(100, 200, 255, 0.15);
color: #64c8ff;
border: 1px solid rgba(100, 200, 255, 0.3);
}
.rd-provider-secondary {
background: rgba(200, 150, 255, 0.15);
color: #c896ff;
border: 1px solid rgba(200, 150, 255, 0.3);
}
.rd-provider-accent {
background: rgba(255, 107, 74, 0.15);
color: var(--accent);
border: 1px solid rgba(255, 107, 74, 0.3);
}
.rd-provider-degraded {
opacity: 0.8;
}
.rd-provider-status {
display: inline-block;
font-size: 10px;
font-weight: 500;
opacity: 0.8;
}
.rd-provider-label {
display: inline;
}
@@ -1,52 +1,16 @@
import { useState, useCallback } from 'react' import { useState } from 'react'
import ResearchDisplay from './ResearchDisplay' import ResearchDisplay from './ResearchDisplay'
const API_URL = '/api' const API_URL = '/api'
const PROVIDER_LABELS = {
ollama: { label: 'Local AI', icon: '🏠', className: 'provider-local' },
gemini: { label: 'Gemini', icon: '✨', className: 'provider-gemini' },
openrouter: { label: 'OpenRouter', icon: '🔀', className: 'provider-openrouter' },
opencode: { label: 'OpenCode', icon: '💡', className: 'provider-opencode' },
exa: { label: 'Exa Search', icon: '🔍', className: 'provider-exa' },
}
const ERROR_MESSAGES = {
'Failed to fetch': 'Network error — check your connection and try again.',
'Load failed': 'Network error — check your connection and try again.',
'429': 'AI provider is rate-limited. Retrying with fallback…',
'503': 'Service unavailable. Retrying with fallback…',
'500': 'Server error while fetching research.',
}
function normalizeErrorMessage(err) {
for (const [key, msg] of Object.entries(ERROR_MESSAGES)) {
if (err.message.includes(key)) return msg
}
return err.message || 'An unexpected error occurred.'
}
function ProviderBadge({ provider }) {
if (!provider) return null
const meta = PROVIDER_LABELS[provider] || { label: provider, icon: '🤖', className: 'provider-unknown' }
return (
<span className={`provider-badge ${meta.className}`} title={`Powered by ${meta.label}`}>
{meta.icon} {meta.label}
</span>
)
}
function ExerciseResearchPanel({ exerciseId, exerciseName }) { function ExerciseResearchPanel({ exerciseId, exerciseName }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [research, setResearch] = useState(null) const [research, setResearch] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [provider, setProvider] = useState(null)
const [retryCount, setRetryCount] = useState(0)
const fetchResearch = useCallback(async (attempt = 0) => { const fetchResearch = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, { const res = await fetch(`${API_URL}/exercises/${exerciseId}/research`, {
method: 'POST', method: 'POST',
@@ -54,66 +18,40 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
body: JSON.stringify({}) body: JSON.stringify({})
}) })
// Parse response regardless of status
const data = await res.json();
if (!res.ok) { if (!res.ok) {
let errMsg = `Request failed (${res.status})` throw new Error(data.error || data.message || 'Failed to fetch research')
try {
const data = await res.json()
errMsg = data.error || errMsg
} catch {
// ignore JSON parse error
}
// Auto-retry once on 429/503 (provider fallback happening on backend)
if ((res.status === 429 || res.status === 503) && attempt < 1) {
console.warn(`[Research] Status ${res.status}, retrying (attempt ${attempt + 1})…`)
setRetryCount(c => c + 1)
setTimeout(() => fetchResearch(attempt + 1), 2000)
return
}
throw new Error(errMsg)
} }
const data = await res.json() // Include provider and status info from response
setResearch(data) setResearch({
summary: data.summary,
// Extract provider from stored record or response metadata results: data.results,
const detectedProvider = provider: data.provider,
data.provider || status: data.status
(data.stored && data.stored.provider) || })
null
setProvider(detectedProvider)
setRetryCount(0)
} catch (err) { } catch (err) {
console.error('[Research] Fetch failed:', err) console.error('Research fetch error:', err);
setError(normalizeErrorMessage(err)) setError(err.message)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [exerciseId]) }
const handleFetch = () => fetchResearch(0)
return ( return (
<div className="research-panel"> <div className="research-panel">
<div className="research-panel-header"> <div className="research-panel-header">
<h3 className="research-panel-title">Research</h3> <h3 className="research-panel-title">Research</h3>
<div className="research-panel-controls"> <button
{provider && <ProviderBadge provider={provider} />} className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`}
<button onClick={fetchResearch}
className={`btn ${research ? 'btn-secondary' : 'btn-primary'} research-btn`} disabled={loading}
onClick={handleFetch} title={research ? 'Refresh research results' : 'Fetch research for this exercise'}
disabled={loading} >
> {loading ? 'Fetching…' : research ? 'Refresh' : 'Get Research'}
{loading </button>
? retryCount > 0
? `Retrying (${retryCount})…`
: 'Fetching…'
: research
? 'Refresh'
: 'Get Research'}
</button>
</div>
</div> </div>
<ResearchDisplay <ResearchDisplay
@@ -122,7 +60,6 @@ function ExerciseResearchPanel({ exerciseId, exerciseName }) {
data={research} data={research}
name={exerciseName} name={exerciseName}
onDismiss={() => setError(null)} onDismiss={() => setError(null)}
onRetry={handleFetch}
/> />
</div> </div>
) )
+53 -25
View File
@@ -9,23 +9,16 @@ function ResearchLoadingSkeleton({ exerciseName }) {
) )
} }
function ResearchError({ message, onDismiss, onRetry }) { function ResearchError({ message, onDismiss }) {
return ( return (
<div className="rd-error" role="alert"> <div className="rd-error" role="alert">
<span className="rd-error-icon" aria-hidden="true"></span> <span className="rd-error-icon" aria-hidden="true"></span>
<span className="rd-error-message">{message}</span> <span className="rd-error-message">{message}</span>
<div className="rd-error-actions"> {onDismiss && (
{onRetry && ( <button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
<button className="rd-retry btn btn-sm btn-primary" onClick={onRetry}> ×
Retry </button>
</button> )}
)}
{onDismiss && (
<button className="rd-dismiss" onClick={onDismiss} aria-label="Dismiss error">
×
</button>
)}
</div>
</div> </div>
) )
} }
@@ -46,28 +39,56 @@ function ResearchSourceCard({ result, index }) {
{result.snippet && ( {result.snippet && (
<p className="rd-source-snippet">{result.snippet}</p> <p className="rd-source-snippet">{result.snippet}</p>
)} )}
{result.isFallback && (
<span className="rd-source-badge">Suggested</span>
)}
</li> </li>
) )
} }
function ResearchProviderBadge({ provider, status }) {
if (!provider) return null;
const badgeConfig = {
exa: { emoji: '🔍', label: 'Exa Search', color: 'primary' },
fallback: { emoji: '🔗', label: 'Web Sources', color: 'secondary' },
gemini: { emoji: '✨', label: 'AI Summary', color: 'accent' },
openrouter: { emoji: '🤖', label: 'AI Powered', color: 'accent' }
};
const config = badgeConfig[provider] || { emoji: '📊', label: provider, color: 'secondary' };
const isDegraded = status === 'degraded';
return (
<div className={`rd-provider-badge rd-provider-${config.color} ${isDegraded ? 'rd-provider-degraded' : ''}`}>
<span aria-hidden="true">{config.emoji}</span>
<span className="rd-provider-label">{config.label}</span>
{isDegraded && (
<span className="rd-provider-status" title="Fallback source - primary API unavailable">
(Fallback)
</span>
)}
</div>
);
}
/** /**
* ResearchDisplay — pure presentational component. * ResearchDisplay — pure presentational component.
* *
* Props: * Props:
* loading {boolean} Show loading skeleton * loading {boolean} Show loading skeleton
* error {string} Error message to display * error {string} Error message to display
* data {object} Research data: { summary, results } * data {object} Research data: { summary, results, provider, status }
* name {string} Exercise name (shown during loading) * name {string} Exercise name (shown during loading)
* onDismiss {function} Clear error callback * onDismiss {function} Clear error callback
* onRetry {function} Retry fetch callback
*/ */
function ResearchDisplay({ loading, error, data, name, onDismiss, onRetry }) { function ResearchDisplay({ loading, error, data, name, onDismiss }) {
if (loading) { if (loading) {
return <ResearchLoadingSkeleton exerciseName={name} /> return <ResearchLoadingSkeleton exerciseName={name} />
} }
if (error) { if (error) {
return <ResearchError message={error} onDismiss={onDismiss} onRetry={onRetry} /> return <ResearchError message={error} onDismiss={onDismiss} />
} }
if (!data) return null if (!data) return null
@@ -77,15 +98,22 @@ function ResearchDisplay({ loading, error, data, name, onDismiss, onRetry }) {
return ( return (
<div className="rd-results"> <div className="rd-results">
{hasSummary && ( <div className="rd-header">
<div className="rd-summary"> <div className="rd-header-content">
<h4 className="rd-section-title"> {hasSummary && (
<span className="rd-section-icon" aria-hidden="true">📋</span> <div className="rd-summary">
Summary <h4 className="rd-section-title">
</h4> <span className="rd-section-icon" aria-hidden="true">📋</span>
<p className="rd-summary-text">{data.summary}</p> Summary
</h4>
<p className="rd-summary-text">{data.summary}</p>
</div>
)}
</div> </div>
)} {data.provider && (
<ResearchProviderBadge provider={data.provider} status={data.status} />
)}
</div>
{hasSources && ( {hasSources && (
<div className="rd-sources"> <div className="rd-sources">