diff --git a/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.ts b/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.ts index 23c470e06..08f97d308 100644 --- a/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.ts +++ b/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.ts @@ -414,6 +414,10 @@ export async function buildAnswer({ return sum + valueInBaseCurrency; }, 0); + fallbackSections.push( + `Total portfolio value: ${portfolioAnalysis.totalValueInBaseCurrency.toFixed(2)} ${userCurrency} across ${portfolioAnalysis.holdingsCount} holdings.` + ); + if (totalLongValue > 0) { const topLongHoldingsSummary = longHoldings .slice(0, 3) diff --git a/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts b/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts index d90f201cb..d7e33cff7 100644 --- a/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai-agent.utils.spec.ts @@ -48,6 +48,14 @@ describe('AiAgentUtils', () => { ).toEqual(['market_data_lookup']); }); + it('selects portfolio analysis for portfolio value query wording', () => { + expect( + determineToolPlan({ + query: 'how much money i have?' + }) + ).toEqual(['portfolio_analysis']); + }); + it('returns no tools when no clear tool keyword exists', () => { expect( determineToolPlan({ diff --git a/apps/api/src/app/endpoints/ai/ai-agent.utils.ts b/apps/api/src/app/endpoints/ai/ai-agent.utils.ts index c5369cb5e..3bf4b431f 100644 --- a/apps/api/src/app/endpoints/ai/ai-agent.utils.ts +++ b/apps/api/src/app/endpoints/ai/ai-agent.utils.ts @@ -49,11 +49,17 @@ const REBALANCE_KEYWORDS = [ ]; const STRESS_TEST_KEYWORDS = ['crash', 'drawdown', 'shock', 'stress']; +const PORTFOLIO_VALUE_QUERY_PATTERNS = [ + /\bhow much(?:\s+\w+){0,4}\s+(?:money|cash|value|worth)\b.*\b(?:i|my)\b.*\b(?:have|own)\b/, + /\b(?:net\s+worth|portfolio\s+value|portfolio\s+worth|account\s+balance|total\s+portfolio\s+value)\b/ +]; const ANSWER_NUMERIC_INTENT_KEYWORDS = [ 'allocat', + 'balance', 'drawdown', 'hhi', 'market', + 'money', 'performance', 'price', 'quote', @@ -61,7 +67,8 @@ const ANSWER_NUMERIC_INTENT_KEYWORDS = [ 'risk', 'shock', 'stress', - 'trim' + 'trim', + 'worth' ]; const ANSWER_ACTIONABLE_KEYWORDS = [ 'add', @@ -275,6 +282,11 @@ export function determineToolPlan({ const hasStressTestIntent = STRESS_TEST_KEYWORDS.some((keyword) => { return normalizedQuery.includes(keyword); }); + const hasPortfolioValueIntent = PORTFOLIO_VALUE_QUERY_PATTERNS.some( + (pattern) => { + return pattern.test(normalizedQuery); + } + ); if ( normalizedQuery.includes('portfolio') || @@ -286,6 +298,10 @@ export function determineToolPlan({ selectedTools.add('portfolio_analysis'); } + if (hasPortfolioValueIntent) { + selectedTools.add('portfolio_analysis'); + } + if ( normalizedQuery.includes('risk') || normalizedQuery.includes('concentration') || diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index 09f98abb6..9c96a628b 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -291,6 +291,46 @@ describe('AiService', () => { 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('routes ambiguous action follow-up query through recommendation tools when finance memory exists', async () => { portfolioService.getDetails.mockResolvedValue({ holdings: {