feature/05-exercise-encyclopedia #4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user