feat(chat): add guided next actions

This commit is contained in:
root 2026-05-07 07:25:00 +00:00
parent d160beadfd
commit 73db534d7d
3 changed files with 209 additions and 22 deletions

View File

@ -232,6 +232,7 @@ interface ChatProfilePayload {
type ChatAction =
| 'create_profile'
| 'update_profile'
| 'recommend_profile_change'
| 'explain'
| 'explain_position'
| 'explain_waiting'
@ -354,6 +355,7 @@ interface ChatResponsePayload {
profile?: ChatProfilePayload;
summary: string;
reasoning: string;
nextActions?: string[];
fallback?: 'local_deterministic';
}
@ -1100,7 +1102,11 @@ export class ApiServer {
return {
action: 'explain_position',
summary: `${target.symbol} is currently ${pnl >= 0 ? 'up' : 'down'} ${Math.abs(pnl).toFixed(2)} (${Math.abs(pnlPercent).toFixed(2)}%) under ${profileName}.`,
reasoning: `${nextStep}${targetHints.length > 0 ? ` Current guardrails: ${targetHints.join(' and ')}.` : ''} Entry ${Number(target.entryPrice || 0).toFixed(2)}, current ${Number(target.currentPrice || 0).toFixed(2)}, size ${Number(target.size || 0).toFixed(6)}.`
reasoning: `${nextStep}${targetHints.length > 0 ? ` Current guardrails: ${targetHints.join(' and ')}.` : ''} Entry ${Number(target.entryPrice || 0).toFixed(2)}, current ${Number(target.currentPrice || 0).toFixed(2)}, size ${Number(target.size || 0).toFixed(6)}.`,
nextActions: [
'Open Trade Plans if you want to attach, pause, or resume automated exits.',
'Review Portfolio for the linked holding and current P&L before changing the plan.',
]
};
}
@ -1149,7 +1155,12 @@ export class ApiServer {
summary: summaryParts.join(' ') || 'A recent operational event indicates the trade flow is blocked.',
reasoning: healthNotes.length > 0
? `Operational context also shows ${healthNotes.join(', ')}. The safest next step is to review the related profile, live order state, and reconciliation surfaces before retrying execution.`
: 'The safest next step is to review the related profile, live order state, and recent operational events before retrying execution.'
: 'The safest next step is to review the related profile, live order state, and recent operational events before retrying execution.',
nextActions: [
'Check the latest operational event and order failure reason for the affected symbol.',
'If the blocker is persistent, inspect the Admin reconciliation surfaces before retrying.',
'If the issue is configuration-driven, ask the copilot to recommend a profile change.',
]
};
}
@ -1173,7 +1184,11 @@ export class ApiServer {
summary: `${label} on ${target.symbol || 'the current symbol'} is currently ${executionStatus.toLowerCase()} with signal ${signalDirection}.${summarySource ? ` ${summarySource}` : ''}`,
reasoning: target.executionCode
? `Execution code: ${target.executionCode}. This means the bot evaluated the setup but did not proceed yet.${target.orderId ? ` Related order: ${target.orderId}.` : ''}`
: 'The bot is still evaluating rule alignment, risk constraints, or session eligibility before entering.'
: 'The bot is still evaluating rule alignment, risk constraints, or session eligibility before entering.',
nextActions: [
'Review the active profile rules and recent symbol signal panel to see which rule or guardrail is not aligned.',
'If the waiting state persists, ask the copilot to recommend a profile change for this setup.',
]
};
}
@ -1200,7 +1215,69 @@ export class ApiServer {
: `Reconciliation currently looks healthy with no active mismatches, NO_GO trades, or quarantined parity trades.`,
reasoning: degraded
? `Missing-from-exchange: ${missingExchange}. Missing-in-DB: ${missingDb}. Auto-closed parity trades: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}`
: `Auto-closed parity trades this cycle: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}`
: `Auto-closed parity trades this cycle: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}`,
nextActions: degraded
? [
'Open the Admin reconciliation audit to inspect mismatches, NO_GO trades, and any quarantined rows.',
'Avoid forcing new execution on the affected profile until the reconciliation issue is understood.',
]
: [
'No immediate action is needed. Continue monitoring normal runtime health and recent operational events.',
]
};
}
private buildProfileChangeRecommendation(message: string, chatContext: ChatRequestContext): ChatResponsePayload | null {
const targetProfile = this.detectProfileToUpdate(message, chatContext.profiles)
|| (Array.isArray(chatContext.profiles) ? chatContext.profiles[0] : null);
if (!targetProfile) return null;
const execution = targetProfile?.strategy_config?.execution || {};
const riskLimits = targetProfile?.strategy_config?.riskLimits || {};
const currentRisk = Number(targetProfile?.risk_per_trade_percent || 1);
const currentCooldown = Number(execution.cooldownMinutes ?? 30);
const currentOpenTrades = Number(riskLimits.maxOpenTrades ?? 3);
const waitingSignal = (chatContext.runtime.signalContexts || []).find((signal) =>
String(signal.profileId || '') === String(targetProfile.id || '')
|| String(signal.profileName || '').toLowerCase() === String(targetProfile.name || '').toLowerCase()
);
const recommendedProfile: ChatProfilePayload = {
...targetProfile,
id: targetProfile.id,
name: targetProfile.name || 'Recommended Profile Change',
allocated_capital: Number(targetProfile.allocated_capital || 1000),
risk_per_trade_percent: Math.max(0.5, Number((currentRisk * 0.85).toFixed(2))),
symbols: String(targetProfile.symbols || 'BTC/USDT'),
is_active: targetProfile.is_active !== false,
strategy_config: {
...targetProfile.strategy_config,
rules: Array.isArray(targetProfile?.strategy_config?.rules) ? targetProfile.strategy_config.rules : [],
riskLimits: {
maxDailyLossUsd: Number(riskLimits.maxDailyLossUsd || 50),
maxOpenTrades: Math.max(1, currentOpenTrades - (waitingSignal?.executionCode === 'rule_ratio_not_met' ? 1 : 0)),
maxConsecutiveLosses: Number(riskLimits.maxConsecutiveLosses ?? 2),
},
execution: {
orderType: execution.orderType === 'limit' ? 'limit' : 'market',
cooldownMinutes: Math.max(10, currentCooldown - (waitingSignal?.executionCode === 'rule_ratio_not_met' ? 10 : 0)),
entryMode: execution.entryMode === 'long_only' ? 'long_only' : 'both',
}
}
};
return {
action: 'recommend_profile_change',
profile: recommendedProfile,
summary: `I recommend tuning "${recommendedProfile.name}" to improve execution quality without changing its overall strategy intent.`,
reasoning: waitingSignal?.executionCode === 'rule_ratio_not_met'
? `Recent signal context shows ${waitingSignal.symbol} is waiting on rule alignment. I reduced cooldown pressure and slightly tightened exposure so the profile can recycle opportunities more cleanly.`
: 'I preserved the existing strategy structure and adjusted only the higher-leverage controls such as risk per trade, cooldown, and open-trade exposure.',
nextActions: [
'Review the suggested profile draft below before applying it.',
'After applying, monitor the next signal cycle to see whether the waiting or blocker state improves.',
]
};
}
@ -1218,6 +1295,7 @@ export class ApiServer {
private buildLocalChatFallback(message: string, context: ChatRequestContext): ChatResponsePayload {
const lower = String(message || '').toLowerCase();
const asksForReconciliation = /\b(reconciliation|reconcile|quarantine|manual review|stale order|no[_\s-]*go|drift)\b/i.test(lower);
const asksForRecommendation = /\b(recommend|improve|tune|optimi[sz]e|adjust my profile|suggest a profile change)\b/i.test(lower);
const asksForWaiting = /\b(why no trade|why.*no trade|why didn'?t.*trade|why did not.*trade|why.*enter|waiting for|no entry|no signal)\b/i.test(lower);
const asksForBlocker = /\b(why.*(blocked|stuck|failed|not execute|didn'?t execute|did not execute)|blocker|blocked|manual trader unavailable|unauthorized|forbidden|failed order)\b/i.test(lower);
const asksForPosition = /\b(position|holding|portfolio|pnl|profit|loss|exit|take profit|stop loss)\b/i.test(lower);
@ -1228,6 +1306,19 @@ export class ApiServer {
return this.buildReconciliationSummary(context);
}
if (asksForRecommendation) {
return this.buildProfileChangeRecommendation(message, context)
|| {
action: 'explain',
summary: 'I could not find a concrete profile to tune from the current scoped context.',
reasoning: 'Mention a specific profile name or ask after selecting the profile you want to improve.',
nextActions: [
'Name the profile you want tuned, for example: "Tune High Risk Scalper".',
'If the issue is execution-related, ask why the trade is waiting or blocked first.',
]
};
}
if (asksForBlocker) {
return this.buildBlockerExplanation(message, context)
|| {
@ -2991,7 +3082,7 @@ AVAILABLE RULES (use these exact ruleId values):
PROFILE SCHEMA:
{
"action": "create_profile" | "update_profile" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation",
"action": "create_profile" | "update_profile" | "recommend_profile_change" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation",
"profile": {
"name": string,
"allocated_capital": number,
@ -3005,7 +3096,8 @@ PROFILE SCHEMA:
}
},
"summary": string (1-2 sentence human-readable summary of what you did),
"reasoning": string (brief explanation of why you chose these parameters or what operational state means)
"reasoning": string (brief explanation of why you chose these parameters or what operational state means),
"nextActions": string[] (optional safe next steps for the user)
}
CURRENT CONTEXT (existing profiles):
@ -3017,16 +3109,17 @@ ${JSON.stringify(chatContext.runtime, null, 2)}
RULES:
1. For "create_profile": generate a complete profile with sensible defaults based on the user's description.
2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same.
3. For "explain": use it for general educational answers. No profile needed.
4. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed.
5. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed.
6. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
7. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
8. Only include "profile" when the action is create_profile or update_profile.
9. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
10. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
11. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety.
12. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`;
3. For "recommend_profile_change": include a profile draft with the recommended adjustments and explain why.
4. For "explain": use it for general educational answers. No profile needed.
5. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed.
6. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. No profile needed.
7. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
8. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
9. Only include "profile" when the action is create_profile, update_profile, or recommend_profile_change.
10. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
11. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
12. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety.
13. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`;
try {
let aiResponse: string | null = null;
@ -3066,6 +3159,7 @@ RULES:
const allowedActions = new Set<ChatAction>([
'create_profile',
'update_profile',
'recommend_profile_change',
'explain',
'explain_position',
'explain_waiting',

View File

@ -255,4 +255,60 @@ describe('ChatControl DOM flow', () => {
])
);
});
it('renders suggested next actions and applies a recommended profile change as an update', async () => {
getPlatformAccessTokenMock.mockResolvedValue('token-4');
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
summary: 'I recommend tuning "High Risk Scalper ⚡" to improve execution quality without changing its overall strategy intent.',
reasoning: 'Recent signal context shows BTC/USDT is waiting on rule alignment.',
nextActions: [
'Review the suggested profile draft below before applying it.',
'After applying, monitor the next signal cycle to see whether the waiting state improves.'
],
action: 'recommend_profile_change',
profile: {
id: 'p1',
name: 'High Risk Scalper ⚡',
allocated_capital: 1000,
risk_per_trade_percent: 1.1,
symbols: 'BTC/USDT',
is_active: true,
strategy_config: {
rules: [{ ruleId: 'TrendBiasRule', enabled: true }],
riskLimits: { maxDailyLossUsd: 50, maxOpenTrades: 2, maxConsecutiveLosses: 2 },
execution: { orderType: 'market', cooldownMinutes: 20, entryMode: 'both' }
}
}
})
} as any);
const onApplyProfile = vi.fn(async () => ({ success: true }));
const user = userEvent.setup();
render(<ChatControl profiles={profilesFixture} botState={botStateFixture} onApplyProfile={onApplyProfile} />);
await user.click(screen.getAllByRole('button')[0]);
await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Recommend a profile change for High Risk Scalper ⚡{enter}');
await waitFor(() => {
expect(screen.getByText(/Suggested next actions/i)).toBeInTheDocument();
expect(screen.getByText(/Review the suggested profile draft below before applying it/i)).toBeInTheDocument();
expect(screen.getByText(/Suggested Profile Change/i)).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /Apply to Dashboard/i }));
await waitFor(() => {
expect(onApplyProfile).toHaveBeenCalledWith(
'update_profile',
expect.objectContaining({
id: 'p1',
name: 'High Risk Scalper ⚡',
risk_per_trade_percent: 1.1,
})
);
});
});
});

View File

@ -19,6 +19,7 @@ interface ChatMessage {
content: string;
profileData?: any;
action?: ChatAssistantAction;
nextActions?: string[];
timestamp: Date;
}
@ -31,8 +32,10 @@ interface ChatControlProps {
type ChatAssistantAction =
| 'create_profile'
| 'update_profile'
| 'recommend_profile_change'
| 'explain'
| 'explain_position'
| 'explain_waiting'
| 'explain_blocker'
| 'summarize_reconciliation';
@ -100,8 +103,8 @@ export const normalizeProfileForApply = (profileData: any) => ({
is_active: profileData?.is_active !== false,
});
const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' =>
action === 'create_profile' || action === 'update_profile';
const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' | 'recommend_profile_change' =>
action === 'create_profile' || action === 'update_profile' || action === 'recommend_profile_change';
const summarizeRuntimeContext = (botState: BotState) => ({
signalContexts: Object.entries(botState.symbols ?? {})
@ -388,6 +391,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
content: data.summary || data.reasoning || 'Profile configuration generated.',
profileData: data.profile || null,
action: data.action as ChatAssistantAction | undefined,
nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined,
timestamp: new Date(),
};
@ -412,14 +416,17 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
if (msg.profileData && isProfileMutationAction(msg.action)) {
const activeDraft = draftProfiles[msg.id] || msg.profileData;
const payload = normalizeProfileForApply(activeDraft);
const result = await onApplyProfile(msg.action, payload);
const applyAction = msg.action === 'recommend_profile_change'
? (payload.id ? 'update_profile' : 'create_profile')
: msg.action;
const result = await onApplyProfile(applyAction, payload);
if (result.success) {
setAppliedIds(prev => new Set(prev).add(msg.id));
closeDraftEditor(msg.id);
setMessages(prev => [...prev, {
id: Date.now(),
role: 'assistant',
content: msg.action === 'create_profile'
content: applyAction === 'create_profile'
? `Profile "${payload.name}" has been created successfully! It's now visible in Strategy Clusters and the bot will pick it up on next sync (~60s).`
: `Profile "${payload.name}" has been updated successfully!`,
timestamp: new Date(),
@ -428,7 +435,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
setMessages(prev => [...prev, {
id: Date.now(),
role: 'assistant',
content: `Failed to ${msg.action === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`,
content: `Failed to ${applyAction === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`,
timestamp: new Date(),
}]);
}
@ -638,6 +645,32 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
{msg.content}
</div>
{msg.nextActions && msg.nextActions.length > 0 ? (
<div className="mt-2 rounded-xl px-3 py-2.5" style={{
background: 'var(--card-elevated)',
border: '1px solid var(--border)',
}}>
<div className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Suggested next actions
</div>
<div className="flex flex-col gap-1.5">
{msg.nextActions.map((nextAction, index) => (
<div
key={`${msg.id}-next-${index}`}
className="rounded-lg px-2.5 py-2 text-[11px] leading-relaxed"
style={{
background: 'var(--accent-soft)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}}
>
{index + 1}. {nextAction}
</div>
))}
</div>
</div>
) : null}
{/* Profile preview card */}
{msg.profileData && isProfileMutationAction(msg.action) && (() => {
const activeProfileData = draftProfiles[msg.id] || msg.profileData;
@ -659,7 +692,11 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
<div className="flex items-center gap-2">
<Zap size={10} className="text-[var(--primary)]" />
<span className="text-[10px] font-bold text-[var(--muted-foreground)] uppercase tracking-wider">
{msg.action === 'create_profile' ? 'New Profile' : 'Update Profile'}
{msg.action === 'create_profile'
? 'New Profile'
: msg.action === 'recommend_profile_change'
? 'Suggested Profile Change'
: 'Update Profile'}
</span>
</div>
<Button