diff --git a/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.spec.ts b/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.spec.ts index 7792a85c4..88c78cc1d 100644 --- a/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.spec.ts @@ -107,6 +107,50 @@ describe('AiAgentChatHelpers', () => { expect(answer).toContain('AAPL'); }); + it('uses recommendation-composer prompt structure for action-intent queries', async () => { + const generateText = jest.fn().mockResolvedValue({ + text: 'Summary: reduce top concentration with staged reallocation. Option 1 uses new money first. Option 2 trims overweight exposure gradually.' + }); + + await buildAnswer({ + generateText, + languageCode: 'en', + memory: { turns: [] }, + portfolioAnalysis: { + allocationSum: 1, + holdings: [ + { + allocationInPercentage: 0.66, + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + valueInBaseCurrency: 6600 + }, + { + allocationInPercentage: 0.34, + dataSource: DataSource.YAHOO, + symbol: 'VTI', + valueInBaseCurrency: 3400 + } + ], + holdingsCount: 2, + totalValueInBaseCurrency: 10000 + }, + query: 'What should I do to diversify?', + userCurrency: 'USD' + }); + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('Recommendation context (JSON):') + }) + ); + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('Option 1 (new money first)') + }) + ); + }); + it('parses and persists concise response-style preference updates', () => { const result = resolvePreferenceUpdate({ query: 'Remember to keep responses concise.', 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 0a485d4ff..1dc3bf46b 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 @@ -30,6 +30,8 @@ const DETAILED_RESPONSE_STYLE_PATTERN = /\b(?:(?:detailed|verbose|longer)\s+(?:answers?|responses?|replies?)|(?:answers?|responses?|replies?)\s+(?:detailed|verbose|longer)|(?:answer|reply)\s+(?:in detail|verbosely)|(?:more|extra)\s+detail)\b/i; const PREFERENCE_RECALL_PATTERN = /\b(?:what do you remember about me|show (?:my )?preferences?|what are my preferences?|which preferences (?:do|did) you (?:remember|save))\b/i; +const RECOMMENDATION_INTENT_PATTERN = + /\b(?:how do i|what should i do|help me|fix|reduce|diversif|deconcentrat|rebalance|recommend|what can i do)\b/i; export const AI_AGENT_MEMORY_MAX_TURNS = 10; @@ -78,6 +80,144 @@ function getResponseInstruction({ return `Write a concise response with actionable insight and avoid speculation.`; } +function isRecommendationIntentQuery(query: string) { + return RECOMMENDATION_INTENT_PATTERN.test(query.trim().toLowerCase()); +} + +function extractTargetConcentration(query: string) { + const targetConcentrationPattern = + /\b(?:below|under|to)\s*(\d{1,2}(?:\.\d{1,2})?)\s*%/i; + const match = targetConcentrationPattern.exec(query); + + if (!match) { + return undefined; + } + + const parsed = Number.parseFloat(match[1]); + + if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= 100) { + return undefined; + } + + return parsed / 100; +} + +function buildRecommendationContext({ + portfolioAnalysis, + query, + riskAssessment +}: { + portfolioAnalysis?: PortfolioAnalysisResult; + query: string; + riskAssessment?: RiskAssessmentResult; +}) { + const longHoldings = (portfolioAnalysis?.holdings ?? []) + .filter(({ valueInBaseCurrency }) => { + return valueInBaseCurrency > 0; + }) + .sort((a, b) => { + return b.valueInBaseCurrency - a.valueInBaseCurrency; + }); + const totalLongValue = longHoldings.reduce((sum, { valueInBaseCurrency }) => { + return sum + valueInBaseCurrency; + }, 0); + const topContributors = longHoldings.slice(0, 3).map(({ symbol, valueInBaseCurrency }) => { + return { + name: symbol, + pct: + totalLongValue > 0 + ? Number((valueInBaseCurrency / totalLongValue).toFixed(4)) + : 0 + }; + }); + const topHoldingPct = + riskAssessment?.topHoldingAllocation ?? + (topContributors.length > 0 ? topContributors[0].pct : 0); + + return { + concentration: { + band: riskAssessment?.concentrationBand ?? 'unknown', + currentPct: Number(topHoldingPct.toFixed(4)), + dimension: 'single_asset', + targetPct: Number((extractTargetConcentration(query) ?? 0.35).toFixed(4)), + topContributors + }, + constraints: { + accountType: 'unknown', + canAddNewMoney: 'unknown', + productUniverse: 'unknown', + region: 'unknown', + taxSensitivity: 'unknown', + timeframe: 'unknown' + }, + userIntent: 'recommend' + }; +} + +function buildRecommendationFallback({ + memory, + portfolioAnalysis, + riskAssessment +}: { + memory: AiAgentMemoryState; + portfolioAnalysis?: PortfolioAnalysisResult; + riskAssessment?: RiskAssessmentResult; +}) { + const longHoldings = (portfolioAnalysis?.holdings ?? []) + .filter(({ valueInBaseCurrency }) => { + return valueInBaseCurrency > 0; + }) + .sort((a, b) => { + return b.valueInBaseCurrency - a.valueInBaseCurrency; + }); + const totalLongValue = longHoldings.reduce((sum, { valueInBaseCurrency }) => { + return sum + valueInBaseCurrency; + }, 0); + const topHolding = longHoldings[0]; + const topHoldingPct = + riskAssessment?.topHoldingAllocation ?? + (topHolding && totalLongValue > 0 + ? topHolding.valueInBaseCurrency / totalLongValue + : 0); + const targetConcentration = 0.35; + + if (!topHolding) { + return undefined; + } + + const reallocationGap = Math.max(topHoldingPct - targetConcentration, 0); + const reallocationGapPct = (reallocationGap * 100).toFixed(1); + const currentTopPct = (topHoldingPct * 100).toFixed(1); + const topAllocationsSummary = longHoldings + .slice(0, 3) + .map(({ symbol, valueInBaseCurrency }) => { + const allocation = totalLongValue > 0 + ? (valueInBaseCurrency / totalLongValue) * 100 + : 0; + + return `${symbol} ${allocation.toFixed(1)}%`; + }) + .join(', '); + const recommendationSections: string[] = []; + + if (memory.turns.length > 0) { + recommendationSections.push( + `Session memory applied from ${memory.turns.length} prior turn(s).` + ); + } + + recommendationSections.push( + `Summary: concentration is ${riskAssessment?.concentrationBand ?? 'elevated'} with ${topHolding.symbol} at ${currentTopPct}% of long exposure.`, + `Largest long allocations: ${topAllocationsSummary}.`, + `Option 1 (new money first): Next-step allocation: direct 80-100% of new contributions to positions outside ${topHolding.symbol} until the top holding approaches 35%.`, + `Option 2 (sell and rebalance): Next-step allocation: trim ${topHolding.symbol} by about ${reallocationGapPct} percentage points in staged rebalances and rotate into underweight diversified exposures.`, + 'Assumptions: taxable status, account type, and product universe were not provided.', + 'Next questions: account type (taxable vs tax-advantaged), tax sensitivity (low/medium/high), and whether new-money-only rebalancing is preferred.' + ); + + return recommendationSections.join('\n'); +} + export function isPreferenceRecallQuery(query: string) { return PREFERENCE_RECALL_PATTERN.test(query.trim().toLowerCase()); } @@ -204,6 +344,7 @@ export async function buildAnswer({ ].some((keyword) => { return normalizedQuery.includes(keyword); }); + const hasRecommendationIntent = isRecommendationIntentQuery(query); if (memory.turns.length > 0) { fallbackSections.push( @@ -302,15 +443,35 @@ export async function buildAnswer({ const fallbackAnswer = userPreferences?.responseStyle === 'concise' ? fallbackSections.slice(0, 2).join('\n') : fallbackSections.join('\n'); - const llmPrompt = [ - `You are a neutral financial assistant.`, - `User currency: ${userCurrency}`, - `Language code: ${languageCode}`, - `Query: ${query}`, - `Context summary:`, - fallbackAnswer, - getResponseInstruction({ userPreferences }) - ].join('\n'); + const recommendationContext = buildRecommendationContext({ + portfolioAnalysis, + query, + riskAssessment + }); + const llmPrompt = hasRecommendationIntent + ? [ + `You are a neutral financial assistant.`, + `User currency: ${userCurrency}`, + `Language code: ${languageCode}`, + `Query: ${query}`, + `Recommendation context (JSON):`, + JSON.stringify(recommendationContext), + `Context summary:`, + fallbackAnswer, + `Task: provide 2-3 policy-bounded options to improve diversification with concrete allocation targets or percentage ranges.`, + `Output sections: Summary, Assumptions, Option 1 (new money first), Option 2 (sell and rebalance), Risk notes, Next questions (max 3).`, + `Do not rely on a single hardcoded ETF unless the user explicitly requests a product. Ask for missing constraints when needed.`, + getResponseInstruction({ userPreferences }) + ].join('\n') + : [ + `You are a neutral financial assistant.`, + `User currency: ${userCurrency}`, + `Language code: ${languageCode}`, + `Query: ${query}`, + `Context summary:`, + fallbackAnswer, + getResponseInstruction({ userPreferences }) + ].join('\n'); const llmTimeoutInMs = getLlmTimeoutInMs(); const abortController = new AbortController(); let timeoutId: NodeJS.Timeout | undefined; @@ -348,6 +509,18 @@ export async function buildAnswer({ } } + if (hasRecommendationIntent) { + const recommendationFallback = buildRecommendationFallback({ + memory, + portfolioAnalysis, + riskAssessment + }); + + if (recommendationFallback) { + return recommendationFallback; + } + } + return fallbackAnswer; }