From 53f026aee2673ce9f8c9051fedfa6b665e12877a Mon Sep 17 00:00:00 2001 From: Clawd Agent Date: Mon, 2 Mar 2026 14:10:32 +0100 Subject: [PATCH] feat(05-02): exa-search research integration --- backend/package.json | 6 +- backend/src/index.js | 12 ++- backend/src/routes/exerciseResearch.js | 81 +++++++++++++++++++ backend/src/services/exaSearch.js | 75 +++++++++++++++++ .../exercise-research.integration.test.js | 80 ++++++++++++++++++ db/migrations/006_add_research_results.sql | 13 +++ 6 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 backend/src/routes/exerciseResearch.js create mode 100644 backend/src/services/exaSearch.js create mode 100644 backend/test/integration/exercise-research.integration.test.js create mode 100644 db/migrations/006_add_research_results.sql diff --git a/backend/package.json b/backend/package.json index ec9b081..83de4e7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "nodemon src/index.js" + "dev": "nodemon src/index.js", + "test": "node --test" }, "dependencies": { "bcryptjs": "^2.4.3", @@ -15,6 +16,7 @@ "pg": "^8.11.3" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "supertest": "^6.3.3" } } diff --git a/backend/src/index.js b/backend/src/index.js index cc21852..f8ac86d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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; diff --git a/backend/src/routes/exerciseResearch.js b/backend/src/routes/exerciseResearch.js new file mode 100644 index 0000000..7d85eeb --- /dev/null +++ b/backend/src/routes/exerciseResearch.js @@ -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 +}; diff --git a/backend/src/services/exaSearch.js b/backend/src/services/exaSearch.js new file mode 100644 index 0000000..2841b35 --- /dev/null +++ b/backend/src/services/exaSearch.js @@ -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 +}; diff --git a/backend/test/integration/exercise-research.integration.test.js b/backend/test/integration/exercise-research.integration.test.js new file mode 100644 index 0000000..3d5b3f9 --- /dev/null +++ b/backend/test/integration/exercise-research.integration.test.js @@ -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'); +}); diff --git a/db/migrations/006_add_research_results.sql b/db/migrations/006_add_research_results.sql new file mode 100644 index 0000000..59dfa79 --- /dev/null +++ b/db/migrations/006_add_research_results.sql @@ -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);