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
+4 -2
View File
@@ -5,7 +5,8 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon src/index.js" "dev": "nodemon src/index.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@@ -15,6 +16,7 @@
"pg": "^8.11.3" "pg": "^8.11.3"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2",
"supertest": "^6.3.3"
} }
} }
+6
View File
@@ -3,6 +3,8 @@ const cors = require('cors');
const { Pool } = require('pg'); const { Pool } = require('pg');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { createExerciseResearchRouter } = require('./routes/exerciseResearch');
const { searchExerciseResearch } = require('./services/exaSearch');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -18,6 +20,7 @@ const pool = new Pool({
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch: searchExerciseResearch }));
const authMiddleware = (req, res, next) => { const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]; const token = req.headers.authorization?.split(' ')[1];
@@ -394,9 +397,11 @@ app.get('/api/today/:programId', async (req, res) => {
} }
}); });
if (require.main === module) {
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Gravl API running on port ${PORT}`); console.log(`Gravl API running on port ${PORT}`);
}); });
}
// ============================================ // ============================================
// Custom Workouts API (Phase 4: Workout Modification) // 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
};
@@ -0,0 +1,80 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const express = require('express');
const request = require('supertest');
const { createExerciseResearchRouter } = require('../../src/routes/exerciseResearch');
const buildPoolMock = ({ exerciseRow }) => ({
query: async (text) => {
if (text.includes('FROM exercises')) {
return { rows: exerciseRow ? [exerciseRow] : [] };
}
if (text.includes('INSERT INTO research_results')) {
return { rows: [{ id: 1, created_at: '2026-03-02T00:00:00.000Z' }] };
}
return { rows: [] };
}
});
const buildApp = ({ pool, exaSearch }) => {
const app = express();
app.use(express.json());
app.use('/api/exercises', createExerciseResearchRouter({ pool, exaSearch }));
return app;
};
test('Exercise research returns summary and results', async () => {
const pool = buildPoolMock({
exerciseRow: {
id: 1,
name: 'Bench Press',
description: 'Barbell press'
}
});
const exaSearch = async ({ query, numResults }) => ({
summary: `Summary for ${query} (${numResults})`,
results: [
{ title: 'Guide', url: 'https://example.com', snippet: 'Bench press form.' }
]
});
const app = buildApp({ pool, exaSearch });
const response = await request(app)
.post('/api/exercises/1/research')
.send({ query: 'Bench press technique', num_results: 3 });
assert.equal(response.statusCode, 200);
assert.equal(response.body.exercise.id, 1);
assert.equal(response.body.summary, 'Summary for Bench press technique (3)');
assert.equal(response.body.results.length, 1);
assert.ok(response.body.stored);
});
test('Exercise research returns 404 when exercise missing', async () => {
const pool = buildPoolMock({ exerciseRow: null });
const exaSearch = async () => {
throw new Error('Should not call exa');
};
const app = buildApp({ pool, exaSearch });
const response = await request(app)
.post('/api/exercises/999/research')
.send({ query: 'Missing' });
assert.equal(response.statusCode, 404);
assert.equal(response.body.error, 'Exercise not found');
});
test('Exercise research validates id', async () => {
const pool = buildPoolMock({ exerciseRow: null });
const exaSearch = async () => ({ summary: '', results: [] });
const app = buildApp({ pool, exaSearch });
const response = await request(app)
.post('/api/exercises/not-a-number/research')
.send({ query: 'Bench' });
assert.equal(response.statusCode, 400);
assert.equal(response.body.error, 'Exercise id must be an integer');
});
@@ -0,0 +1,13 @@
-- Store exercise research summaries and sources
CREATE TABLE IF NOT EXISTS research_results (
id SERIAL PRIMARY KEY,
exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
query TEXT NOT NULL,
summary TEXT,
results JSONB NOT NULL DEFAULT '[]'::jsonb,
provider VARCHAR(50) NOT NULL DEFAULT 'exa',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_research_results_exercise_id ON research_results(exercise_id);
CREATE INDEX IF NOT EXISTS idx_research_results_created_at ON research_results(created_at);