Replace Anthropic API with local Ollama for AI advisor

This commit is contained in:
2026-04-12 17:36:34 -07:00
parent 17de171d26
commit 37f8e2e7c3
5 changed files with 51 additions and 67 deletions
-32
View File
@@ -10,9 +10,6 @@ importers:
tehriehlbudget-backend:
dependencies:
'@anthropic-ai/sdk':
specifier: ^0.88.0
version: 0.88.0(zod@4.3.6)
'@nestjs/common':
specifier: ^11.0.1
version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -268,15 +265,6 @@ packages:
resolution: {integrity: sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@anthropic-ai/sdk@0.88.0':
resolution: {integrity: sha512-QQOtB5U9ZBJQj6y1ICmDZl14LWa4JCiJRoihI+0yuZ4OjbONrakP0yLwPv4DJFb3VYCtQM31bTOpCBMs2zghPw==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@asamuzakjp/css-color@5.1.10':
resolution: {integrity: sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -3527,10 +3515,6 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -4641,9 +4625,6 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -5178,12 +5159,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@anthropic-ai/sdk@0.88.0(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.6
'@asamuzakjp/css-color@5.1.10':
dependencies:
'@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
@@ -8857,11 +8832,6 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.29.2
ts-algebra: 2.0.0
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -9951,8 +9921,6 @@ snapshots:
dependencies:
punycode: 2.3.1
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
+2 -1
View File
@@ -2,4 +2,5 @@ DATABASE_URL="postgresql://admin:development_password@localhost:5432/tehriehlbud
SUPABASE_URL="https://your-project.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
ENCRYPTION_KEY="generate-with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
ANTHROPIC_API_KEY="your-anthropic-api-key"
OLLAMA_URL="http://localhost:11434"
OLLAMA_MODEL="llama3"
-1
View File
@@ -24,7 +24,6 @@
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.88.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "^2.1.1",
@@ -5,17 +5,9 @@ import { AggregationsService } from '../aggregations/aggregations.service';
jest.mock('@prisma/client', () => ({ PrismaClient: class {} }));
// Mock Anthropic SDK
jest.mock('@anthropic-ai/sdk', () => {
return {
__esModule: true,
default: jest.fn().mockImplementation(() => ({
messages: {
create: jest.fn(),
},
})),
};
});
// Mock global fetch for Ollama calls
const mockFetch = jest.fn();
global.fetch = mockFetch;
describe('AdvisorService', () => {
let service: AdvisorService;
@@ -37,7 +29,13 @@ describe('AdvisorService', () => {
};
const mockConfig = {
getOrThrow: jest.fn().mockReturnValue('test-api-key'),
get: jest.fn((key: string) => {
const vals: Record<string, string> = {
OLLAMA_URL: 'http://localhost:11434',
OLLAMA_MODEL: 'llama3',
};
return vals[key];
}),
};
beforeEach(async () => {
@@ -91,24 +89,35 @@ describe('AdvisorService', () => {
});
describe('getAdvice', () => {
it('should fetch aggregation data and return AI insights', async () => {
const mockClient = service['anthropic'];
(mockClient.messages.create as jest.Mock).mockResolvedValue({
content: [
{
type: 'text',
text: '1. Your dining out spending is 14% of income — consider meal prepping.\n2. Strong savings rate of 36% — keep it up.\n3. Consider setting a monthly entertainment budget.',
it('should call Ollama and return insights', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
message: {
content: '1. Great savings rate!\n2. Reduce dining costs.',
},
],
}),
});
const result = await service.getAdvice(userId);
expect(mockAggregations.getSummary).toHaveBeenCalled();
expect(mockAggregations.getSpendingByCategory).toHaveBeenCalled();
expect(mockClient.messages.create).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:11434/api/chat',
expect.objectContaining({ method: 'POST' }),
);
expect(result).toHaveProperty('insights');
expect(typeof result.insights).toBe('string');
expect(result.insights).toContain('savings rate');
});
it('should throw on Ollama failure', async () => {
mockFetch.mockResolvedValue({
ok: false,
statusText: 'Service Unavailable',
});
await expect(service.getAdvice(userId)).rejects.toThrow('Ollama request failed');
});
});
});
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AggregationsService } from '../aggregations/aggregations.service';
import Anthropic from '@anthropic-ai/sdk';
const PII_FIELDS = [
'userId',
@@ -15,15 +14,15 @@ const PII_FIELDS = [
@Injectable()
export class AdvisorService {
private anthropic: Anthropic;
private ollamaUrl: string;
private ollamaModel: string;
constructor(
private aggregations: AggregationsService,
private config: ConfigService,
) {
this.anthropic = new Anthropic({
apiKey: this.config.getOrThrow<string>('ANTHROPIC_API_KEY'),
});
this.ollamaUrl = this.config.get<string>('OLLAMA_URL') || 'http://localhost:11434';
this.ollamaModel = this.config.get<string>('OLLAMA_MODEL') || 'llama3';
}
stripPII(data: any): any {
@@ -71,14 +70,22 @@ ${anonymized.categories.map((c: any) => `- ${c.name}: $${c.amount}`).join('\n')}
Provide specific, numbered insights. Be encouraging but honest.`;
const response = await this.anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
const response = await fetch(`${this.ollamaUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.ollamaModel,
messages: [{ role: 'user', content: prompt }],
stream: false,
}),
});
const insights =
response.content[0].type === 'text' ? response.content[0].text : '';
if (!response.ok) {
throw new Error(`Ollama request failed: ${response.statusText}`);
}
const data = await response.json();
const insights = data.message?.content || '';
return { insights, generatedAt: new Date().toISOString() };
}