feat(chat): add guided next actions
This commit is contained in:
parent
d160beadfd
commit
73db534d7d
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user