Browse Source

feat(ai): add recommendation-mode prompting for action intents

pull/6395/head
Max P 1 month ago
parent
commit
bdf1a67478
  1. 44
      apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.spec.ts
  2. 191
      apps/api/src/app/endpoints/ai/ai-agent.chat.helpers.ts

44
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.',

191
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;
}

Loading…
Cancel
Save