Replace Anthropic API with local Ollama for AI advisor
This commit is contained in:
Generated
-32
@@ -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,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"
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user