Add POST /api/semantic-diff endpoint for AI-powered code change explanations #11

Open
iswa wants to merge 1 commits from feature/semantic-diff-explainer into master
6 changed files with 183 additions and 0 deletions

View File

@@ -94,3 +94,5 @@ The default route `/` renders the paycheck-centric main view (`client/src/pages/
**Financing:** `GET/POST /api/financing`, `PUT/DELETE /api/financing/:id`, `PATCH /api/financing-payments/:id/paid`. Plans track a total amount, payoff due date, and `start_date`. Payment per period is auto-calculated as `(remaining balance) / (remaining periods)`. Split plans (`assigned_paycheck = null`) divide each period's payment across both paychecks. Plans auto-close when fully paid. Financing payments are included in the paycheck remaining balance. `start_date` prevents a plan from appearing on paycheck months before it was created — both virtual previews and `generate` respect this guard.
**Migrations:** SQL files in `db/migrations/` are applied in filename order on server startup. Add new migrations as `00N_description.sql` — they run once and are tracked in the `migrations` table.
**Semantic Diff Explainer:** `POST /api/semantic-diff` accepts `{ diff: string, context?: string }` and returns `{ explanation: string }`. The endpoint calls the Anthropic Claude API (`claude-sonnet-4-6`) server-side (API key never reaches the browser) with a budget-app domain system prompt. Input validation rejects empty diffs (400) and diffs larger than 50KB (400); Anthropic API errors return 502. Requires `ANTHROPIC_API_KEY` in the server environment. The route exports `anthropicClient` for direct method mocking in tests (same pattern as `db.pool.query`).

View File

@@ -8,6 +8,7 @@
"name": "budget-server",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
@@ -19,6 +20,35 @@
"vitest": "^4.1.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz",
"integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -1382,6 +1412,19 @@
"node": ">=0.12.0"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2623,6 +2666,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -9,6 +9,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../app.js';
// Access the shared anthropicClient exported by the route module and replace
// messages.create directly — same pattern as db.pool.query mocking in this codebase.
const semanticDiffRoute = require('../routes/semantic-diff.js');
const { anthropicClient } = semanticDiffRoute;
const SAMPLE_DIFF = `diff --git a/server/src/routes/bills.js b/server/src/routes/bills.js
--- a/server/src/routes/bills.js
+++ b/server/src/routes/bills.js
@@ -10,7 +10,7 @@
- const amount = req.body.amount;
+ const amount = parseFloat(req.body.amount);
`;
describe('POST /api/semantic-diff', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('returns 400 when diff is missing', async () => {
const res = await request(app).post('/api/semantic-diff').send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/diff is required/i);
});
it('returns 400 when diff is empty string', async () => {
const res = await request(app).post('/api/semantic-diff').send({ diff: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/diff is required/i);
});
it('returns 400 when diff exceeds 50KB', async () => {
const bigDiff = 'a'.repeat(51 * 1024);
const res = await request(app).post('/api/semantic-diff').send({ diff: bigDiff });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/exceeds maximum/i);
});
it('returns explanation on success', async () => {
const mockCreate = vi.spyOn(anthropicClient.messages, 'create').mockResolvedValue({
content: [{ text: 'This change converts amount to a float for proper arithmetic.' }],
});
const res = await request(app).post('/api/semantic-diff').send({ diff: SAMPLE_DIFF });
expect(res.status).toBe(200);
expect(res.body.explanation).toBe('This change converts amount to a float for proper arithmetic.');
expect(mockCreate).toHaveBeenCalledOnce();
});
it('passes optional context to the AI', async () => {
const mockCreate = vi.spyOn(anthropicClient.messages, 'create').mockResolvedValue({
content: [{ text: 'Explanation with context.' }],
});
await request(app)
.post('/api/semantic-diff')
.send({ diff: SAMPLE_DIFF, context: 'Fixing a bug in bill amount parsing' });
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.messages[0].content).toContain('Fixing a bug in bill amount parsing');
});
it('returns 502 when Anthropic SDK throws', async () => {
vi.spyOn(anthropicClient.messages, 'create').mockRejectedValue(new Error('API unavailable'));
const res = await request(app).post('/api/semantic-diff').send({ diff: SAMPLE_DIFF });
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/failed to get explanation/i);
});
});

View File

@@ -9,6 +9,7 @@ const actualsRouter = require('./routes/actuals');
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
const summaryRouter = require('./routes/summary');
const { router: financingRouter } = require('./routes/financing');
const semanticDiffRouter = require('./routes/semantic-diff');
const app = express();
@@ -24,6 +25,7 @@ app.use('/api', actualsRouter);
app.use('/api', oneTimeExpensesRouter);
app.use('/api', summaryRouter);
app.use('/api', financingRouter);
app.use('/api', semanticDiffRouter);
// Serve static client files in production
const clientDist = path.join(__dirname, '../../client/dist');

View File

@@ -0,0 +1,56 @@
const express = require('express');
const Anthropic = require('@anthropic-ai/sdk');
const router = express.Router();
// Exported so tests can replace client.messages.create without real API calls
const anthropicClient = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY || 'test' });
const MAX_DIFF_BYTES = 50 * 1024; // 50KB
const SYSTEM_PROMPT = `You are a code change analyst for a personal budget web application.
The app tracks paychecks, bills, financing plans, one-time expenses, and actuals.
Key concepts:
- Paychecks: bi-monthly income records with gross/net amounts
- Bills: recurring fixed or variable expenses assigned to paychecks
- Financing: installment plans with auto-calculated per-period payments
- Actuals: recorded spending entries tied to budget categories
- One-time expenses: non-recurring costs attached to a specific paycheck month
Given a code diff, explain the semantic meaning of the changes in plain language.
Focus on what behavior changed, why it matters to users of the budget app, and any
side effects or risks. Be concise but thorough.`;
router.post('/semantic-diff', async (req, res) => {
const { diff, context } = req.body;
if (!diff || typeof diff !== 'string' || diff.trim().length === 0) {
return res.status(400).json({ error: 'diff is required and must be a non-empty string' });
}
if (Buffer.byteLength(diff, 'utf8') > MAX_DIFF_BYTES) {
return res.status(400).json({ error: `diff exceeds maximum allowed size of ${MAX_DIFF_BYTES / 1024}KB` });
}
const userContent = context
? `Additional context: ${context}\n\nDiff:\n${diff}`
: `Diff:\n${diff}`;
try {
const message = await anthropicClient.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userContent }],
});
const explanation = message.content[0].text;
return res.json({ explanation });
} catch (err) {
console.error('Anthropic API error:', err);
return res.status(502).json({ error: 'Failed to get explanation from AI service' });
}
});
module.exports = router;
module.exports.anthropicClient = anthropicClient;