mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
861 lines
26 KiB
861 lines
26 KiB
import { DataSource } from '@prisma/client';
|
|
|
|
import { AiService } from './ai.service';
|
|
|
|
describe('AiService', () => {
|
|
let dataProviderService: { getQuotes: jest.Mock };
|
|
let portfolioService: { getDetails: jest.Mock };
|
|
let propertyService: { getByKey: jest.Mock };
|
|
let redisCacheService: { get: jest.Mock; set: jest.Mock };
|
|
let aiObservabilityService: {
|
|
captureChatFailure: jest.Mock;
|
|
captureChatSuccess: jest.Mock;
|
|
recordLlmInvocation: jest.Mock;
|
|
recordFeedback: jest.Mock;
|
|
};
|
|
let subject: AiService;
|
|
const originalFetch = global.fetch;
|
|
const originalMinimaxApiKey = process.env.minimax_api_key;
|
|
const originalMinimaxModel = process.env.minimax_model;
|
|
const originalZAiGlmApiKey = process.env.z_ai_glm_api_key;
|
|
const originalZAiGlmModel = process.env.z_ai_glm_model;
|
|
|
|
beforeEach(() => {
|
|
dataProviderService = {
|
|
getQuotes: jest.fn()
|
|
};
|
|
portfolioService = {
|
|
getDetails: jest.fn()
|
|
};
|
|
propertyService = {
|
|
getByKey: jest.fn()
|
|
};
|
|
redisCacheService = {
|
|
get: jest.fn(),
|
|
set: jest.fn()
|
|
};
|
|
aiObservabilityService = {
|
|
captureChatFailure: jest.fn().mockResolvedValue(undefined),
|
|
captureChatSuccess: jest.fn().mockResolvedValue({
|
|
latencyBreakdownInMs: {
|
|
llmGenerationInMs: 9,
|
|
memoryReadInMs: 2,
|
|
memoryWriteInMs: 3,
|
|
toolExecutionInMs: 7
|
|
},
|
|
latencyInMs: 21,
|
|
tokenEstimate: {
|
|
input: 10,
|
|
output: 20,
|
|
total: 30
|
|
},
|
|
traceId: 'trace-1'
|
|
}),
|
|
recordLlmInvocation: jest.fn().mockResolvedValue(undefined),
|
|
recordFeedback: jest.fn()
|
|
};
|
|
|
|
subject = new AiService(
|
|
dataProviderService as never,
|
|
portfolioService as never,
|
|
propertyService as never,
|
|
redisCacheService as never,
|
|
aiObservabilityService as never
|
|
);
|
|
|
|
delete process.env.minimax_api_key;
|
|
delete process.env.minimax_model;
|
|
delete process.env.z_ai_glm_api_key;
|
|
delete process.env.z_ai_glm_model;
|
|
});
|
|
|
|
afterAll(() => {
|
|
global.fetch = originalFetch;
|
|
|
|
if (originalMinimaxApiKey === undefined) {
|
|
delete process.env.minimax_api_key;
|
|
} else {
|
|
process.env.minimax_api_key = originalMinimaxApiKey;
|
|
}
|
|
|
|
if (originalMinimaxModel === undefined) {
|
|
delete process.env.minimax_model;
|
|
} else {
|
|
process.env.minimax_model = originalMinimaxModel;
|
|
}
|
|
|
|
if (originalZAiGlmApiKey === undefined) {
|
|
delete process.env.z_ai_glm_api_key;
|
|
} else {
|
|
process.env.z_ai_glm_api_key = originalZAiGlmApiKey;
|
|
}
|
|
|
|
if (originalZAiGlmModel === undefined) {
|
|
delete process.env.z_ai_glm_model;
|
|
} else {
|
|
process.env.z_ai_glm_model = originalZAiGlmModel;
|
|
}
|
|
});
|
|
|
|
it('runs portfolio, risk, and market tools with structured response fields', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
AAPL: {
|
|
allocationInPercentage: 0.6,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 6000
|
|
},
|
|
MSFT: {
|
|
allocationInPercentage: 0.4,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'MSFT',
|
|
valueInBaseCurrency: 4000
|
|
}
|
|
}
|
|
});
|
|
dataProviderService.getQuotes.mockResolvedValue({
|
|
AAPL: {
|
|
currency: 'USD',
|
|
marketPrice: 210.12,
|
|
marketState: 'REGULAR'
|
|
},
|
|
MSFT: {
|
|
currency: 'USD',
|
|
marketPrice: 455.9,
|
|
marketState: 'REGULAR'
|
|
}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockResolvedValue({
|
|
text: 'Portfolio risk is medium with top holding at 60% and HHI at 0.52 today.'
|
|
} as never);
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Analyze my portfolio risk and price for AAPL',
|
|
sessionId: 'session-1',
|
|
userCurrency: 'USD',
|
|
userId: 'user-1'
|
|
});
|
|
|
|
expect(result.answer).toContain('Portfolio risk');
|
|
expect(result.toolCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
tool: 'portfolio_analysis'
|
|
}),
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
tool: 'risk_assessment'
|
|
}),
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
tool: 'market_data_lookup'
|
|
})
|
|
])
|
|
);
|
|
expect(result.citations.length).toBeGreaterThan(0);
|
|
expect(result.confidence.score).toBeGreaterThanOrEqual(0);
|
|
expect(result.confidence.score).toBeLessThanOrEqual(1);
|
|
expect(result.verification).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ check: 'numerical_consistency' }),
|
|
expect.objectContaining({ check: 'tool_execution' }),
|
|
expect.objectContaining({ check: 'output_completeness' }),
|
|
expect.objectContaining({ check: 'citation_coverage' })
|
|
])
|
|
);
|
|
expect(result.memory).toEqual({
|
|
sessionId: 'session-1',
|
|
turns: 1
|
|
});
|
|
expect(result.observability).toEqual({
|
|
latencyBreakdownInMs: {
|
|
llmGenerationInMs: 9,
|
|
memoryReadInMs: 2,
|
|
memoryWriteInMs: 3,
|
|
toolExecutionInMs: 7
|
|
},
|
|
latencyInMs: 21,
|
|
tokenEstimate: {
|
|
input: 10,
|
|
output: 20,
|
|
total: 30
|
|
},
|
|
traceId: 'trace-1'
|
|
});
|
|
expect(aiObservabilityService.captureChatSuccess).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
latencyBreakdownInMs: expect.objectContaining({
|
|
llmGenerationInMs: expect.any(Number),
|
|
memoryReadInMs: expect.any(Number),
|
|
memoryWriteInMs: expect.any(Number),
|
|
toolExecutionInMs: expect.any(Number)
|
|
})
|
|
})
|
|
);
|
|
expect(redisCacheService.set).toHaveBeenCalledWith(
|
|
'ai-agent-memory-user-1-session-1',
|
|
expect.any(String),
|
|
expect.any(Number)
|
|
);
|
|
});
|
|
|
|
it('keeps memory history and caps turns at the configured limit', async () => {
|
|
const previousTurns = Array.from({ length: 10 }, (_, index) => {
|
|
return {
|
|
answer: `answer-${index}`,
|
|
query: `query-${index}`,
|
|
timestamp: `2026-02-20T00:0${index}:00.000Z`,
|
|
toolCalls: [{ status: 'success', tool: 'portfolio_analysis' }]
|
|
};
|
|
});
|
|
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(
|
|
JSON.stringify({
|
|
turns: previousTurns
|
|
})
|
|
);
|
|
jest.spyOn(subject, 'generateText').mockRejectedValue(new Error('offline'));
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Show my portfolio overview',
|
|
sessionId: 'session-memory',
|
|
userCurrency: 'USD',
|
|
userId: 'user-memory'
|
|
});
|
|
|
|
expect(result.memory.turns).toBe(10);
|
|
const [, payload] = redisCacheService.set.mock.calls[0];
|
|
const persistedMemory = JSON.parse(payload as string);
|
|
expect(persistedMemory.turns).toHaveLength(10);
|
|
expect(
|
|
persistedMemory.turns.find(
|
|
({ query }: { query: string }) => query === 'query-0'
|
|
)
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('enforces direct no-tool route at executor even when symbols are provided', async () => {
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
const generateTextSpy = jest.spyOn(subject, 'generateText');
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Hi',
|
|
sessionId: 'session-direct-route',
|
|
symbols: ['NVDA'],
|
|
userCurrency: 'USD',
|
|
userId: 'user-direct-route'
|
|
});
|
|
|
|
expect(result.answer).toContain('I am Ghostfolio AI');
|
|
expect(result.toolCalls).toEqual([]);
|
|
expect(result.citations).toEqual([]);
|
|
expect(dataProviderService.getQuotes).not.toHaveBeenCalled();
|
|
expect(generateTextSpy).not.toHaveBeenCalled();
|
|
expect(result.verification).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
check: 'numerical_consistency',
|
|
status: 'passed'
|
|
}),
|
|
expect.objectContaining({
|
|
check: 'policy_gating',
|
|
status: 'warning'
|
|
})
|
|
])
|
|
);
|
|
});
|
|
|
|
it('returns arithmetic response on direct no-tool arithmetic query', async () => {
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
const generateTextSpy = jest.spyOn(subject, 'generateText');
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: '2+2',
|
|
sessionId: 'session-arithmetic-route',
|
|
userCurrency: 'USD',
|
|
userId: 'user-arithmetic-route'
|
|
});
|
|
|
|
expect(result.answer).toBe('2+2 = 4');
|
|
expect(result.toolCalls).toEqual([]);
|
|
expect(generateTextSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses portfolio data for "how much money i have?" queries', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
AAPL: {
|
|
allocationInPercentage: 0.6,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 6000
|
|
},
|
|
MSFT: {
|
|
allocationInPercentage: 0.4,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'MSFT',
|
|
valueInBaseCurrency: 4000
|
|
}
|
|
}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockRejectedValue(new Error('offline'));
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'how much money i have?',
|
|
sessionId: 'session-total-value',
|
|
userCurrency: 'USD',
|
|
userId: 'user-total-value'
|
|
});
|
|
|
|
expect(result.answer).toContain('Total portfolio value: 10000.00 USD');
|
|
expect(result.answer).not.toContain('I am Ghostfolio AI');
|
|
expect(result.toolCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
tool: 'portfolio_analysis'
|
|
})
|
|
])
|
|
);
|
|
});
|
|
|
|
it('uses portfolio data for typo portfolio-value queries', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
AAPL: {
|
|
allocationInPercentage: 0.6,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 6000
|
|
},
|
|
MSFT: {
|
|
allocationInPercentage: 0.4,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'MSFT',
|
|
valueInBaseCurrency: 4000
|
|
}
|
|
}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockRejectedValue(new Error('offline'));
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'how much.i ahve money?',
|
|
sessionId: 'session-total-value-typo',
|
|
userCurrency: 'USD',
|
|
userId: 'user-total-value-typo'
|
|
});
|
|
|
|
expect(result.answer).toContain('Total portfolio value: 10000.00 USD');
|
|
expect(result.answer).not.toContain('I am Ghostfolio AI');
|
|
expect(result.toolCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
tool: 'portfolio_analysis'
|
|
})
|
|
])
|
|
);
|
|
});
|
|
|
|
it('routes ambiguous action follow-up query through recommendation tools when finance memory exists', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
USD: {
|
|
allocationInPercentage: 0.665,
|
|
dataSource: DataSource.MANUAL,
|
|
symbol: 'USD',
|
|
valueInBaseCurrency: 6650
|
|
},
|
|
VTI: {
|
|
allocationInPercentage: 0.159,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'VTI',
|
|
valueInBaseCurrency: 1590
|
|
},
|
|
AAPL: {
|
|
allocationInPercentage: 0.085,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 850
|
|
}
|
|
}
|
|
});
|
|
redisCacheService.get.mockImplementation(async (key: string) => {
|
|
if (key.startsWith('ai-agent-memory-user-follow-up-')) {
|
|
return JSON.stringify({
|
|
turns: [
|
|
{
|
|
answer:
|
|
'Risk concentration is high. Top holding allocation is 66.5%.',
|
|
query: 'help me diversify',
|
|
timestamp: '2026-02-24T12:00:00.000Z',
|
|
toolCalls: [
|
|
{ status: 'success', tool: 'portfolio_analysis' },
|
|
{ status: 'success', tool: 'risk_assessment' }
|
|
]
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
return undefined;
|
|
});
|
|
jest.spyOn(subject, 'generateText').mockResolvedValue({
|
|
text: 'Improve concentration by redirecting new cash to underweight holdings, trimming the top position in stages, and reassessing risk after each rebalance checkpoint.'
|
|
} as never);
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'what can i do?',
|
|
sessionId: 'session-follow-up',
|
|
userCurrency: 'USD',
|
|
userId: 'user-follow-up'
|
|
});
|
|
|
|
expect(result.answer).toContain('Option 1 (new money first):');
|
|
expect(result.answer).toContain('Option 2 (sell and rebalance):');
|
|
expect(result.toolCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
tool: 'portfolio_analysis'
|
|
}),
|
|
expect.objectContaining({
|
|
status: 'success',
|
|
tool: 'risk_assessment'
|
|
})
|
|
])
|
|
);
|
|
expect(subject.generateText).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: expect.stringContaining('Recommendation context (JSON):')
|
|
})
|
|
);
|
|
});
|
|
|
|
it('persists and recalls cross-session user preferences for the same user', async () => {
|
|
const redisStore = new Map<string, string>();
|
|
redisCacheService.get.mockImplementation(async (key: string) => {
|
|
return redisStore.get(key);
|
|
});
|
|
redisCacheService.set.mockImplementation(
|
|
async (key: string, value: string) => {
|
|
redisStore.set(key, value);
|
|
}
|
|
);
|
|
|
|
const savePreferenceResult = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Remember to keep responses concise.',
|
|
sessionId: 'session-pref-1',
|
|
userCurrency: 'USD',
|
|
userId: 'user-pref'
|
|
});
|
|
|
|
expect(savePreferenceResult.answer).toContain('Saved preference');
|
|
expect(redisStore.get('ai-agent-preferences-user-pref')).toContain('concise');
|
|
|
|
const recallPreferenceResult = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'What do you remember about me?',
|
|
sessionId: 'session-pref-2',
|
|
userCurrency: 'USD',
|
|
userId: 'user-pref'
|
|
});
|
|
|
|
expect(recallPreferenceResult.answer).toContain(
|
|
'Saved cross-session preferences'
|
|
);
|
|
expect(recallPreferenceResult.answer).toContain('response style: concise');
|
|
});
|
|
|
|
it('applies persisted response-style preferences to LLM prompt generation', async () => {
|
|
const redisStore = new Map<string, string>();
|
|
redisCacheService.get.mockImplementation(async (key: string) => {
|
|
return redisStore.get(key);
|
|
});
|
|
redisCacheService.set.mockImplementation(
|
|
async (key: string, value: string) => {
|
|
redisStore.set(key, value);
|
|
}
|
|
);
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
AAPL: {
|
|
allocationInPercentage: 1,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 1000
|
|
}
|
|
}
|
|
});
|
|
const generateTextSpy = jest.spyOn(subject, 'generateText');
|
|
generateTextSpy.mockResolvedValue({
|
|
text: 'Portfolio concentration is high.'
|
|
} as never);
|
|
|
|
await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Keep responses concise.',
|
|
sessionId: 'session-pref-tools-1',
|
|
userCurrency: 'USD',
|
|
userId: 'user-pref-tools'
|
|
});
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Show my portfolio allocation',
|
|
sessionId: 'session-pref-tools-2',
|
|
userCurrency: 'USD',
|
|
userId: 'user-pref-tools'
|
|
});
|
|
|
|
expect(result.answer.length).toBeGreaterThan(0);
|
|
expect(generateTextSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: expect.stringContaining(
|
|
'User preference: keep the response concise in 1-3 short sentences and avoid speculation.'
|
|
)
|
|
})
|
|
);
|
|
});
|
|
|
|
it('runs rebalance and stress test tools for portfolio scenario prompts', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
AAPL: {
|
|
allocationInPercentage: 0.6,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 6000
|
|
},
|
|
MSFT: {
|
|
allocationInPercentage: 0.4,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'MSFT',
|
|
valueInBaseCurrency: 4000
|
|
}
|
|
}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockResolvedValue({
|
|
text: 'Trim AAPL toward target allocation and monitor stress drawdown.'
|
|
} as never);
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Rebalance my portfolio and run a stress test',
|
|
sessionId: 'session-core-tools',
|
|
userCurrency: 'USD',
|
|
userId: 'user-core-tools'
|
|
});
|
|
|
|
expect(result.toolCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ tool: 'portfolio_analysis' }),
|
|
expect.objectContaining({ tool: 'risk_assessment' }),
|
|
expect.objectContaining({ tool: 'rebalance_plan' }),
|
|
expect.objectContaining({ tool: 'stress_test' })
|
|
])
|
|
);
|
|
expect(result.verification).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
check: 'rebalance_coverage',
|
|
status: 'passed'
|
|
}),
|
|
expect.objectContaining({
|
|
check: 'stress_test_coherence',
|
|
status: 'passed'
|
|
})
|
|
])
|
|
);
|
|
});
|
|
|
|
it('returns deterministic diversification action guidance when generated output is unreliable', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
AAPL: {
|
|
allocationInPercentage: 0.665,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 6650
|
|
},
|
|
VTI: {
|
|
allocationInPercentage: 0.159,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'VTI',
|
|
valueInBaseCurrency: 1590
|
|
},
|
|
MSFT: {
|
|
allocationInPercentage: 0.085,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'MSFT',
|
|
valueInBaseCurrency: 850
|
|
}
|
|
}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockResolvedValue({
|
|
text: 'Diversify.'
|
|
} as never);
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'help me diversify',
|
|
sessionId: 'session-diversify-1',
|
|
userCurrency: 'USD',
|
|
userId: 'user-diversify-1'
|
|
});
|
|
|
|
expect(result.answer).toContain('AAPL');
|
|
expect(result.answer).toContain('Option 1 (new money first):');
|
|
expect(result.answer).toContain('Option 2 (sell and rebalance):');
|
|
expect(result.toolCalls).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ tool: 'portfolio_analysis' }),
|
|
expect.objectContaining({ tool: 'risk_assessment' })
|
|
])
|
|
);
|
|
});
|
|
|
|
it('returns graceful failure metadata when a tool execution fails', async () => {
|
|
dataProviderService.getQuotes.mockRejectedValue(
|
|
new Error('market provider unavailable')
|
|
);
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockResolvedValue({
|
|
text: 'Market data currently has limited availability with 0 quotes returned for the requested symbols.'
|
|
} as never);
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'What is the current price of NVDA?',
|
|
sessionId: 'session-failure',
|
|
userCurrency: 'USD',
|
|
userId: 'user-failure'
|
|
});
|
|
|
|
expect(result.toolCalls).toEqual([
|
|
expect.objectContaining({
|
|
outputSummary: 'market provider unavailable',
|
|
status: 'failed',
|
|
tool: 'market_data_lookup'
|
|
})
|
|
]);
|
|
expect(result.verification).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
check: 'numerical_consistency',
|
|
status: 'passed'
|
|
}),
|
|
expect.objectContaining({
|
|
check: 'tool_execution',
|
|
status: 'warning'
|
|
})
|
|
])
|
|
);
|
|
expect(result.answer).toContain('limited availability');
|
|
});
|
|
|
|
it('flags numerical consistency warning when allocation sum exceeds tolerance', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {
|
|
AAPL: {
|
|
allocationInPercentage: 0.8,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'AAPL',
|
|
valueInBaseCurrency: 8000
|
|
},
|
|
MSFT: {
|
|
allocationInPercentage: 0.3,
|
|
dataSource: DataSource.YAHOO,
|
|
symbol: 'MSFT',
|
|
valueInBaseCurrency: 3000
|
|
}
|
|
}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockRejectedValue(new Error('offline'));
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Show portfolio allocation',
|
|
sessionId: 'session-allocation-warning',
|
|
userCurrency: 'USD',
|
|
userId: 'user-allocation-warning'
|
|
});
|
|
|
|
expect(result.verification).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
check: 'numerical_consistency',
|
|
status: 'warning'
|
|
})
|
|
])
|
|
);
|
|
});
|
|
|
|
it('flags market data coverage warning when only part of symbols resolve', async () => {
|
|
dataProviderService.getQuotes.mockResolvedValue({
|
|
AAPL: {
|
|
currency: 'USD',
|
|
marketPrice: 210.12,
|
|
marketState: 'REGULAR'
|
|
}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
jest.spyOn(subject, 'generateText').mockResolvedValue({
|
|
text: 'Partial market data was returned.'
|
|
} as never);
|
|
|
|
const result = await subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Get market prices for AAPL and TSLA',
|
|
sessionId: 'session-market-coverage-warning',
|
|
symbols: ['AAPL', 'TSLA'],
|
|
userCurrency: 'USD',
|
|
userId: 'user-market-coverage-warning'
|
|
});
|
|
|
|
expect(result.verification).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
check: 'market_data_coverage',
|
|
status: 'warning'
|
|
})
|
|
])
|
|
);
|
|
});
|
|
|
|
it('uses z.ai glm provider when z_ai_glm_api_key is available', async () => {
|
|
process.env.z_ai_glm_api_key = 'zai-key';
|
|
process.env.z_ai_glm_model = 'glm-5';
|
|
|
|
const fetchMock = jest.fn().mockResolvedValue({
|
|
json: jest.fn().mockResolvedValue({
|
|
choices: [{ message: { content: 'zai-response' } }]
|
|
}),
|
|
ok: true
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const result = await subject.generateText({
|
|
prompt: 'hello'
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.z.ai/api/paas/v4/chat/completions',
|
|
expect.objectContaining({
|
|
method: 'POST'
|
|
})
|
|
);
|
|
expect(result).toEqual({
|
|
text: 'zai-response'
|
|
});
|
|
expect(aiObservabilityService.recordLlmInvocation).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
model: 'glm-5',
|
|
provider: 'z_ai_glm',
|
|
responseText: 'zai-response'
|
|
})
|
|
);
|
|
expect(propertyService.getByKey).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to minimax when z.ai request fails', async () => {
|
|
process.env.z_ai_glm_api_key = 'zai-key';
|
|
process.env.minimax_api_key = 'minimax-key';
|
|
process.env.minimax_model = 'MiniMax-M2.5';
|
|
|
|
const fetchMock = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500
|
|
})
|
|
.mockResolvedValueOnce({
|
|
json: jest.fn().mockResolvedValue({
|
|
choices: [{ message: { content: 'minimax-response' } }]
|
|
}),
|
|
ok: true
|
|
});
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const result = await subject.generateText({
|
|
prompt: 'fallback test'
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://api.z.ai/api/paas/v4/chat/completions',
|
|
expect.any(Object)
|
|
);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://api.minimax.io/v1/chat/completions',
|
|
expect.any(Object)
|
|
);
|
|
expect(result).toEqual({
|
|
text: 'minimax-response'
|
|
});
|
|
expect(aiObservabilityService.recordLlmInvocation).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
model: 'glm-5',
|
|
provider: 'z_ai_glm'
|
|
})
|
|
);
|
|
expect(aiObservabilityService.recordLlmInvocation).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
model: 'MiniMax-M2.5',
|
|
provider: 'minimax',
|
|
responseText: 'minimax-response'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('captures observability failure events when chat throws', async () => {
|
|
portfolioService.getDetails.mockResolvedValue({
|
|
holdings: {}
|
|
});
|
|
redisCacheService.get.mockResolvedValue(undefined);
|
|
redisCacheService.set.mockRejectedValue(new Error('redis write failed'));
|
|
jest.spyOn(subject, 'generateText').mockResolvedValue({
|
|
text: 'Fallback response'
|
|
} as never);
|
|
|
|
await expect(
|
|
subject.chat({
|
|
languageCode: 'en',
|
|
query: 'Show my portfolio allocation',
|
|
sessionId: 'session-observability-failure',
|
|
userCurrency: 'USD',
|
|
userId: 'user-observability-failure'
|
|
})
|
|
).rejects.toThrow('redis write failed');
|
|
|
|
expect(aiObservabilityService.captureChatFailure).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
query: 'Show my portfolio allocation',
|
|
sessionId: 'session-observability-failure',
|
|
userId: 'user-observability-failure'
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|