From 73db534d7dfc987f92a31931f6076fc580412f8f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 07:25:00 +0000 Subject: [PATCH] feat(chat): add guided next actions --- backend/src/services/apiServer.ts | 126 +++++++++++++++++--- web/src/components/ChatControl.dom.test.tsx | 56 +++++++++ web/src/components/ChatControl.tsx | 49 +++++++- 3 files changed, 209 insertions(+), 22 deletions(-) diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 6b2576b..4cec9d2 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -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([ 'create_profile', 'update_profile', + 'recommend_profile_change', 'explain', 'explain_position', 'explain_waiting', diff --git a/web/src/components/ChatControl.dom.test.tsx b/web/src/components/ChatControl.dom.test.tsx index 3e4816e..c4cb86f 100644 --- a/web/src/components/ChatControl.dom.test.tsx +++ b/web/src/components/ChatControl.dom.test.tsx @@ -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(); + 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, + }) + ); + }); + }); }); diff --git a/web/src/components/ChatControl.tsx b/web/src/components/ChatControl.tsx index 66f473e..788188f 100644 --- a/web/src/components/ChatControl.tsx +++ b/web/src/components/ChatControl.tsx @@ -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} + {msg.nextActions && msg.nextActions.length > 0 ? ( +
+
+ Suggested next actions +
+
+ {msg.nextActions.map((nextAction, index) => ( +
+ {index + 1}. {nextAction} +
+ ))} +
+
+ ) : 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
- {msg.action === 'create_profile' ? 'New Profile' : 'Update Profile'} + {msg.action === 'create_profile' + ? 'New Profile' + : msg.action === 'recommend_profile_change' + ? 'Suggested Profile Change' + : 'Update Profile'}