feat(05-02): exa-search research integration

This commit is contained in:
2026-03-02 14:10:32 +01:00
parent 994cc9e984
commit 53f026aee2
6 changed files with 262 additions and 5 deletions
+9 -3
View File
@@ -3,6 +3,8 @@ const cors = require('cors');
const { Pool } = require('pg');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
const { searchExerciseResearch } = require('./services/exaSearch');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -18,6 +20,7 @@ const pool = new Pool({
app.use(cors());
app.use(express.json());
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
@@ -394,9 +397,11 @@ app.get('/api/today/:programId', async (req, res) => {
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Gravl API running on port ${PORT}`);
});
if (require.main === module) {
app.listen(PORT, '0.0.0.0', () => {
console.log(`Gravl API running on port ${PORT}`);
});
}
// ============================================
// Custom Workouts API (Phase 4: Workout Modification)
@@ -764,3 +769,4 @@ app.delete('/api/logs', async (req, res) => {
}
});
module.exports = app;
+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const normalizeQuery = (exerciseName, body) => {
if (body && typeof body.query === 'string' && body.query.trim()) {
return body.query.trim();
}
if (body && typeof body.name === 'string' && body.name.trim()) {
return body.name.trim();
}
return `${exerciseName} exercise`;
};
const createExerciseResearchRouter = ({ pool, exaSearch }) => {
if (!pool || typeof pool.query !== 'function') {
throw new Error('Pool with query function is required');
}
if (!exaSearch || typeof exaSearch !== 'function') {
throw new Error('exaSearch function is required');
}
const router = express.Router();
router.post('/:id/research', async (req, res) => {
try {
const exerciseId = Number.parseInt(req.params.id, 10);
if (!Number.isInteger(exerciseId)) {
return res.status(400).json({ error: 'Exercise id must be an integer' });
}
const exerciseResult = await pool.query(
'SELECT id, name, description, muscle_groups, difficulty, equipment_needed FROM exercises WHERE id = $1',
[exerciseId]
);
if (!exerciseResult.rows.length) {
return res.status(404).json({ error: 'Exercise not found' });
}
const exercise = exerciseResult.rows[0];
const query = normalizeQuery(exercise.name, req.body);
const requestedResults = req.body?.num_results;
const numResults = Number.isInteger(requestedResults) && requestedResults > 0
? Math.min(requestedResults, 10)
: 5;
const { summary, results } = await exaSearch({ query, numResults });
let researchRecord = null;
try {
const insertResult = await pool.query(
`INSERT INTO research_results (exercise_id, query, summary, results, provider)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`,
[exerciseId, query, summary, JSON.stringify(results), 'exa']
);
researchRecord = insertResult.rows[0] || null;
} catch (err) {
console.warn('Failed to store research results:', err.message);
}
res.json({
exercise,
query,
summary,
results,
stored: researchRecord
});
} catch (err) {
console.error('Error running exercise research:', err);
res.status(500).json({ error: 'Failed to fetch research' });
}
});
return router;
};
module.exports = {
createExerciseResearchRouter
};
+75
View File
@@ -0,0 +1,75 @@
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(' ');
};
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;
if (!apiKey) {
throw new Error('EXA_API_KEY is not configured');
}
const apiUrl = process.env.EXA_API_URL || DEFAULT_EXA_API_URL;
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
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Exa search failed: ${response.status} ${text}`);
}
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
}));
return {
summary: buildSummary(results),
results
};
};
module.exports = {
searchExerciseResearch
};