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 = type ChatAction =
| 'create_profile' | 'create_profile'
| 'update_profile' | 'update_profile'
| 'recommend_profile_change'
| 'explain' | 'explain'
| 'explain_position' | 'explain_position'
| 'explain_waiting' | 'explain_waiting'
@ -354,6 +355,7 @@ interface ChatResponsePayload {
profile?: ChatProfilePayload; profile?: ChatProfilePayload;
summary: string; summary: string;
reasoning: string; reasoning: string;
nextActions?: string[];
fallback?: 'local_deterministic'; fallback?: 'local_deterministic';
} }
@ -1100,7 +1102,11 @@ export class ApiServer {
return { return {
action: 'explain_position', action: 'explain_position',
summary: `${target.symbol} is currently ${pnl >= 0 ? 'up' : 'down'} ${Math.abs(pnl).toFixed(2)} (${Math.abs(pnlPercent).toFixed(2)}%) under ${profileName}.`, 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.', summary: summaryParts.join(' ') || 'A recent operational event indicates the trade flow is blocked.',
reasoning: healthNotes.length > 0 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.` ? `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}` : ''}`, summary: `${label} on ${target.symbol || 'the current symbol'} is currently ${executionStatus.toLowerCase()} with signal ${signalDirection}.${summarySource ? ` ${summarySource}` : ''}`,
reasoning: target.executionCode 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}.` : ''}` ? `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.`, : `Reconciliation currently looks healthy with no active mismatches, NO_GO trades, or quarantined parity trades.`,
reasoning: degraded reasoning: degraded
? `Missing-from-exchange: ${missingExchange}. Missing-in-DB: ${missingDb}. Auto-closed parity trades: ${autoClosedCount}.${recentReconEvents.length > 0 ? ` Recent events: ${recentReconEvents.join(' | ')}` : ''}` ? `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 { private buildLocalChatFallback(message: string, context: ChatRequestContext): ChatResponsePayload {
const lower = String(message || '').toLowerCase(); const lower = String(message || '').toLowerCase();
const asksForReconciliation = /\b(reconciliation|reconcile|quarantine|manual review|stale order|no[_\s-]*go|drift)\b/i.test(lower); 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 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 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); 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); 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) { if (asksForBlocker) {
return this.buildBlockerExplanation(message, context) return this.buildBlockerExplanation(message, context)
|| { || {
@ -2991,7 +3082,7 @@ AVAILABLE RULES (use these exact ruleId values):
PROFILE SCHEMA: 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": { "profile": {
"name": string, "name": string,
"allocated_capital": number, "allocated_capital": number,
@ -3005,7 +3096,8 @@ PROFILE SCHEMA:
} }
}, },
"summary": string (1-2 sentence human-readable summary of what you did), "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): CURRENT CONTEXT (existing profiles):
@ -3017,16 +3109,17 @@ ${JSON.stringify(chatContext.runtime, null, 2)}
RULES: RULES:
1. For "create_profile": generate a complete profile with sensible defaults based on the user's description. 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. 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. 3. For "recommend_profile_change": include a profile draft with the recommended adjustments and explain why.
4. For "explain_position": use live position context and explain the current holding plus likely next step. No profile needed. 4. For "explain": use it for general educational answers. No profile needed.
5. For "explain_waiting": explain why a profile/symbol has not entered yet using signal/rule/execution context. 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_blocker": explain the most relevant trade/order/operational blocker from runtime context. 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 "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed. 7. For "explain_blocker": explain the most relevant trade/order/operational blocker from runtime context. No profile needed.
8. Only include "profile" when the action is create_profile or update_profile. 8. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
9. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice. 9. Only include "profile" when the action is create_profile, update_profile, or recommend_profile_change.
10. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. 10. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
11. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. 11. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
12. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; 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 { try {
let aiResponse: string | null = null; let aiResponse: string | null = null;
@ -3066,6 +3159,7 @@ RULES:
const allowedActions = new Set<ChatAction>([ const allowedActions = new Set<ChatAction>([
'create_profile', 'create_profile',
'update_profile', 'update_profile',
'recommend_profile_change',
'explain', 'explain',
'explain_position', 'explain_position',
'explain_waiting', '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; content: string;
profileData?: any; profileData?: any;
action?: ChatAssistantAction; action?: ChatAssistantAction;
nextActions?: string[];
timestamp: Date; timestamp: Date;
} }
@ -31,8 +32,10 @@ interface ChatControlProps {
type ChatAssistantAction = type ChatAssistantAction =
| 'create_profile' | 'create_profile'
| 'update_profile' | 'update_profile'
| 'recommend_profile_change'
| 'explain' | 'explain'
| 'explain_position' | 'explain_position'
| 'explain_waiting'
| 'explain_blocker' | 'explain_blocker'
| 'summarize_reconciliation'; | 'summarize_reconciliation';
@ -100,8 +103,8 @@ export const normalizeProfileForApply = (profileData: any) => ({
is_active: profileData?.is_active !== false, is_active: profileData?.is_active !== false,
}); });
const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' => const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' | 'recommend_profile_change' =>
action === 'create_profile' || action === 'update_profile'; action === 'create_profile' || action === 'update_profile' || action === 'recommend_profile_change';
const summarizeRuntimeContext = (botState: BotState) => ({ const summarizeRuntimeContext = (botState: BotState) => ({
signalContexts: Object.entries(botState.symbols ?? {}) signalContexts: Object.entries(botState.symbols ?? {})
@ -388,6 +391,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
content: data.summary || data.reasoning || 'Profile configuration generated.', content: data.summary || data.reasoning || 'Profile configuration generated.',
profileData: data.profile || null, profileData: data.profile || null,
action: data.action as ChatAssistantAction | undefined, action: data.action as ChatAssistantAction | undefined,
nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined,
timestamp: new Date(), timestamp: new Date(),
}; };
@ -412,14 +416,17 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
if (msg.profileData && isProfileMutationAction(msg.action)) { if (msg.profileData && isProfileMutationAction(msg.action)) {
const activeDraft = draftProfiles[msg.id] || msg.profileData; const activeDraft = draftProfiles[msg.id] || msg.profileData;
const payload = normalizeProfileForApply(activeDraft); 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) { if (result.success) {
setAppliedIds(prev => new Set(prev).add(msg.id)); setAppliedIds(prev => new Set(prev).add(msg.id));
closeDraftEditor(msg.id); closeDraftEditor(msg.id);
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: Date.now(), id: Date.now(),
role: 'assistant', 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 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!`, : `Profile "${payload.name}" has been updated successfully!`,
timestamp: new Date(), timestamp: new Date(),
@ -428,7 +435,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: Date.now(), id: Date.now(),
role: 'assistant', 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(), timestamp: new Date(),
}]); }]);
} }
@ -638,6 +645,32 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
{msg.content} {msg.content}
</div> </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 */} {/* Profile preview card */}
{msg.profileData && isProfileMutationAction(msg.action) && (() => { {msg.profileData && isProfileMutationAction(msg.action) && (() => {
const activeProfileData = draftProfiles[msg.id] || msg.profileData; 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"> <div className="flex items-center gap-2">
<Zap size={10} className="text-[var(--primary)]" /> <Zap size={10} className="text-[var(--primary)]" />
<span className="text-[10px] font-bold text-[var(--muted-foreground)] uppercase tracking-wider"> <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> </span>
</div> </div>
<Button <Button