feat(chat): add structured copilot insights

This commit is contained in:
root 2026-05-07 08:03:08 +00:00
parent 6797452e7b
commit 1a794d2365
4 changed files with 107 additions and 5 deletions

View File

@ -363,6 +363,7 @@ interface ChatResponsePayload {
profile?: ChatProfilePayload; profile?: ChatProfilePayload;
summary: string; summary: string;
reasoning: string; reasoning: string;
insights?: string[];
nextActions?: string[]; nextActions?: string[];
quickLinks?: ChatQuickLink[]; quickLinks?: ChatQuickLink[];
fallback?: 'local_deterministic'; fallback?: 'local_deterministic';
@ -1112,6 +1113,11 @@ export class ApiServer {
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)}.`,
insights: [
`Position side: ${side}`,
`Entry price: ${Number(target.entryPrice || 0).toFixed(2)}`,
`Current price: ${Number(target.currentPrice || 0).toFixed(2)}`,
],
nextActions: [ nextActions: [
'Open Trade Plans if you want to attach, pause, or resume automated exits.', '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.', 'Review Portfolio for the linked holding and current P&L before changing the plan.',
@ -1169,6 +1175,11 @@ export class ApiServer {
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.',
insights: [
recentFailure?.tradeId ? `Trade ID: ${recentFailure.tradeId}` : null,
recentFailure?.reason ? `Latest failure: ${recentFailure.reason}` : null,
recentEvent?.message ? `Latest operational event: ${recentEvent.message}` : null,
].filter(Boolean) as string[],
nextActions: [ nextActions: [
'Check the latest operational event and order failure reason for the affected symbol.', '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 blocker is persistent, inspect the Admin reconciliation surfaces before retrying.',
@ -1202,6 +1213,11 @@ export class ApiServer {
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.',
insights: [
`Execution status: ${executionStatus}`,
target.executionCode ? `Execution code: ${target.executionCode}` : null,
target.orderId ? `Related order: ${target.orderId}` : null,
].filter(Boolean) as string[],
nextActions: [ nextActions: [
'Review the active profile rules and recent symbol signal panel to see which rule or guardrail is not aligned.', '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.', 'If the waiting state persists, ask the copilot to recommend a profile change for this setup.',
@ -1236,6 +1252,11 @@ export class ApiServer {
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(' | ')}` : ''}`,
insights: [
`Missing from exchange: ${missingExchange}`,
`Missing in DB: ${missingDb}`,
`Auto-closed parity trades: ${autoClosedCount}`,
],
nextActions: degraded nextActions: degraded
? [ ? [
'Open the Admin reconciliation audit to inspect mismatches, NO_GO trades, and any quarantined rows.', 'Open the Admin reconciliation audit to inspect mismatches, NO_GO trades, and any quarantined rows.',
@ -1302,6 +1323,11 @@ export class ApiServer {
reasoning: waitingSignal?.executionCode === 'rule_ratio_not_met' 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.` ? `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.', : 'I preserved the existing strategy structure and adjusted only the higher-leverage controls such as risk per trade, cooldown, and open-trade exposure.',
insights: [
`Suggested risk/trade: ${recommendedProfile.risk_per_trade_percent}%`,
`Suggested max open trades: ${recommendedProfile.strategy_config?.riskLimits?.maxOpenTrades ?? 'n/a'}`,
`Suggested cooldown: ${recommendedProfile.strategy_config?.execution?.cooldownMinutes ?? 'n/a'} min`,
],
nextActions: [ nextActions: [
'Review the suggested profile draft below before applying it.', '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.', 'After applying, monitor the next signal cycle to see whether the waiting or blocker state improves.',
@ -1323,6 +1349,11 @@ export class ApiServer {
action: 'recommend_trade_plan', action: 'recommend_trade_plan',
summary: `A Trade Plan would help manage ${position.symbol} more explicitly from here.`, summary: `A Trade Plan would help manage ${position.symbol} more explicitly from here.`,
reasoning: `You already have a live holding on ${position.symbol}. The safest automation step is to open Plans and attach or refine an exit plan tied to trade ${position.tradeId || 'the current holding'}.`, reasoning: `You already have a live holding on ${position.symbol}. The safest automation step is to open Plans and attach or refine an exit plan tied to trade ${position.tradeId || 'the current holding'}.`,
insights: [
`Live holding: ${position.symbol}`,
position.tradeId ? `Trade ID: ${position.tradeId}` : null,
Number.isFinite(Number(position.unrealizedPnl)) ? `Current unrealized P&L: ${Number(position.unrealizedPnl || 0).toFixed(2)}` : null,
].filter(Boolean) as string[],
nextActions: [ nextActions: [
'Open Plans to attach or adjust a profit exit for this holding.', 'Open Plans to attach or adjust a profit exit for this holding.',
'If you want to stop automation, convert the position to a long-term hold in Plans.', 'If you want to stop automation, convert the position to a long-term hold in Plans.',
@ -1346,6 +1377,12 @@ export class ApiServer {
action: 'review_recent_trades', action: 'review_recent_trades',
summary: `Your last ${trades.length} closed trades show ${winners} winners, ${losers} losers, and net P&L of ${totalPnl.toFixed(2)}.${topReason}`, summary: `Your last ${trades.length} closed trades show ${winners} winners, ${losers} losers, and net P&L of ${totalPnl.toFixed(2)}.${topReason}`,
reasoning: 'Use the most recent closed trades as the fastest feedback loop for whether your current profile settings and Trade Plans are producing the outcomes you expect.', reasoning: 'Use the most recent closed trades as the fastest feedback loop for whether your current profile settings and Trade Plans are producing the outcomes you expect.',
insights: trades.slice(0, 3).map((trade) => {
const symbol = String(trade.symbol || 'unknown');
const pnl = Number(trade.pnl || 0).toFixed(2);
const reason = trade.reason ? ` (${trade.reason})` : '';
return `${symbol}: ${pnl}${reason}`;
}),
nextActions: [ nextActions: [
'Open Portfolio trade history to inspect the latest winners and losers in detail.', 'Open Portfolio trade history to inspect the latest winners and losers in detail.',
'If one profile is underperforming repeatedly, ask the copilot to recommend a profile change for it.', 'If one profile is underperforming repeatedly, ask the copilot to recommend a profile change for it.',
@ -1365,6 +1402,11 @@ export class ApiServer {
action: 'recommend_reconciliation_followup', action: 'recommend_reconciliation_followup',
summary: `The safest next remediation step is to inspect reconciliation before forcing more execution. Current counts: mismatches=${mismatchCount}, no_go=${noGoCount}, quarantined=${quarantinedCount}.`, summary: `The safest next remediation step is to inspect reconciliation before forcing more execution. Current counts: mismatches=${mismatchCount}, no_go=${noGoCount}, quarantined=${quarantinedCount}.`,
reasoning: 'When reconciliation is degraded, fixing the underlying mismatch is safer than retrying trades blindly because it prevents lifecycle drift and duplicate execution.', reasoning: 'When reconciliation is degraded, fixing the underlying mismatch is safer than retrying trades blindly because it prevents lifecycle drift and duplicate execution.',
insights: [
`Mismatch count: ${mismatchCount}`,
`NO_GO trades: ${noGoCount}`,
`Quarantined trades: ${quarantinedCount}`,
],
nextActions: [ nextActions: [
'Open the Admin reconciliation panel and inspect the newest mismatches first.', 'Open the Admin reconciliation panel and inspect the newest mismatches first.',
'Use the affected symbol and profile from the latest operational events to narrow the problem quickly.', 'Use the affected symbol and profile from the latest operational events to narrow the problem quickly.',
@ -3223,6 +3265,7 @@ 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),
"insights": string[] (optional concise evidence or key facts),
"nextActions": string[] (optional safe next steps for the user), "nextActions": string[] (optional safe next steps for the user),
"quickLinks": [{ kind: "portfolio" | "plans" | "settings", ... }] (optional safe in-app destinations) "quickLinks": [{ kind: "portfolio" | "plans" | "settings", ... }] (optional safe in-app destinations)
} }
@ -3247,7 +3290,7 @@ RULES:
11. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed. 11. For "summarize_reconciliation": summarize reconciliation health, mismatches, quarantines, and operational implications. No profile needed.
12. Only include "profile" when the action is create_profile, update_profile, or recommend_profile_change. 12. Only include "profile" when the action is create_profile, update_profile, or recommend_profile_change.
13. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice. 13. If runtime context is relevant, prefer concrete symbols, profile names, counts, and current state over generic advice.
14. Include safe "nextActions" and "quickLinks" whenever they would help the user move to the right app surface. 14. Include safe "insights", "nextActions", and "quickLinks" whenever they would help the user move to the right app surface.
15. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. 15. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled.
16. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. 16. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety.
17. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; 17. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`;

View File

@ -55,13 +55,17 @@ function testChatActionContract() {
} }
function testQuickLinkContract() { function testQuickLinkContract() {
assertSourceIncludes(
`"insights": string[] (optional concise evidence or key facts)`,
'chat prompt contract must document structured insights',
);
assertSourceIncludes( assertSourceIncludes(
`"quickLinks": [{ kind: "portfolio" | "plans" | "settings", ... }] (optional safe in-app destinations)`, `"quickLinks": [{ kind: "portfolio" | "plans" | "settings", ... }] (optional safe in-app destinations)`,
'chat prompt contract must document quickLinks', 'chat prompt contract must document quickLinks',
); );
assertSourceIncludes( assertSourceIncludes(
'Include safe "nextActions" and "quickLinks" whenever they would help the user move to the right app surface.', 'Include safe "insights", "nextActions", and "quickLinks" whenever they would help the user move to the right app surface.',
'chat prompt rules must require safe nextActions/quickLinks guidance', 'chat prompt rules must require safe insights/nextActions/quickLinks guidance',
); );
assertSourceIncludes( assertSourceIncludes(
"{ kind: 'plans', label: 'Manage in Plans'", "{ kind: 'plans', label: 'Manage in Plans'",

View File

@ -115,7 +115,7 @@ describe('ChatControl DOM flow', () => {
await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Create a conservative profile{enter}'); await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'Create a conservative profile{enter}');
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/Error: Not authenticated/i)).toBeInTheDocument(); expect(screen.getByText(/session is not authorized for chat right now/i)).toBeInTheDocument();
}, { timeout: 3000 }); }, { timeout: 3000 });
}, 15000); }, 15000);
@ -221,6 +221,7 @@ describe('ChatControl DOM flow', () => {
summary: 'BTC/USDT is currently up 375.00 (2.50%) under High Risk Scalper ⚡.', summary: 'BTC/USDT is currently up 375.00 (2.50%) under High Risk Scalper ⚡.',
reasoning: 'The bot is primarily waiting for either your profit target, stop-loss, or a management change from you.', reasoning: 'The bot is primarily waiting for either your profit target, stop-loss, or a management change from you.',
action: 'explain_position', action: 'explain_position',
insights: ['Position side: BUY', 'Entry price: 60000.00'],
}) })
} as any); } as any);
@ -233,6 +234,8 @@ describe('ChatControl DOM flow', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/BTC\/USDT is currently up 375.00/i)).toBeInTheDocument(); expect(screen.getByText(/BTC\/USDT is currently up 375.00/i)).toBeInTheDocument();
expect(screen.getByText(/waiting for either your profit target/i)).toBeInTheDocument(); expect(screen.getByText(/waiting for either your profit target/i)).toBeInTheDocument();
expect(screen.getByText(/Key facts/i)).toBeInTheDocument();
expect(screen.getByText(/Position side: BUY/i)).toBeInTheDocument();
}); });
expect(screen.queryByRole('button', { name: /Apply to Dashboard/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Apply to Dashboard/i })).not.toBeInTheDocument();

View File

@ -21,6 +21,7 @@ interface ChatMessage {
content: string; content: string;
profileData?: any; profileData?: any;
action?: ChatAssistantAction; action?: ChatAssistantAction;
insights?: string[];
nextActions?: string[]; nextActions?: string[];
quickLinks?: ChatQuickLink[]; quickLinks?: ChatQuickLink[];
timestamp: Date; timestamp: Date;
@ -36,6 +37,9 @@ type ChatAssistantAction =
| 'create_profile' | 'create_profile'
| 'update_profile' | 'update_profile'
| 'recommend_profile_change' | 'recommend_profile_change'
| 'recommend_trade_plan'
| 'recommend_reconciliation_followup'
| 'review_recent_trades'
| 'explain' | 'explain'
| 'explain_position' | 'explain_position'
| 'explain_waiting' | 'explain_waiting'
@ -61,6 +65,9 @@ export const BASE_QUICK_ACTIONS: QuickAction[] = [
{ label: 'Why no trade?', prompt: 'Why has no trade fired yet for my active profile? Explain what the bot is waiting for.' }, { label: 'Why no trade?', prompt: 'Why has no trade fired yet for my active profile? Explain what the bot is waiting for.' },
{ label: 'Explain blocker', prompt: 'Why is a trade or exit blocked right now? Explain the main blocker.' }, { label: 'Explain blocker', prompt: 'Why is a trade or exit blocked right now? Explain the main blocker.' },
{ label: 'Recon summary', prompt: 'Summarize reconciliation health, stale orders, and any manual review risk right now.' }, { label: 'Recon summary', prompt: 'Summarize reconciliation health, stale orders, and any manual review risk right now.' },
{ label: 'Fix reconciliation', prompt: 'What should I do about reconciliation right now? Recommend the safest follow-up.' },
{ label: 'Review recent trades', prompt: 'Review my recent trades and tell me what to focus on next.' },
{ label: 'Manage live holding', prompt: 'Recommend the safest Trade Plan action for my current live holding.' },
{ label: 'What rules?', prompt: 'What rules should I use for a day trading strategy?' }, { label: 'What rules?', prompt: 'What rules should I use for a day trading strategy?' },
{ label: 'Modify existing', prompt: 'Show me my existing profiles and suggest improvements' }, { label: 'Modify existing', prompt: 'Show me my existing profiles and suggest improvements' },
]; ];
@ -114,6 +121,24 @@ export const normalizeProfileForApply = (profileData: any) => ({
const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' | 'recommend_profile_change' => const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' | 'recommend_profile_change' =>
action === 'create_profile' || action === 'update_profile' || action === 'recommend_profile_change'; action === 'create_profile' || action === 'update_profile' || action === 'recommend_profile_change';
const formatChatError = (error: unknown) => {
const message = String((error as { message?: string })?.message || '').trim();
const lower = message.toLowerCase();
if (!message) {
return 'The assistant could not complete that request. Please try again.';
}
if (lower.includes('not authenticated') || lower.includes('unauthorized') || lower.includes('forbidden')) {
return 'Your session is not authorized for chat right now. Sign in again, then retry.';
}
if (lower.includes('failed to fetch') || lower.includes('network') || lower.includes('load failed')) {
return 'The chat service is temporarily unreachable. Check connectivity and retry.';
}
if (lower.includes('timeout')) {
return 'The chat request timed out before the assistant finished. Retry once the backend is responsive.';
}
return `The assistant could not complete that request: ${message}`;
};
const summarizeRuntimeContext = (botState: BotState) => ({ const summarizeRuntimeContext = (botState: BotState) => ({
signalContexts: Object.entries(botState.symbols ?? {}) signalContexts: Object.entries(botState.symbols ?? {})
.flatMap(([symbol, symbolState]) => .flatMap(([symbol, symbolState]) =>
@ -423,6 +448,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,
insights: Array.isArray(data.insights) ? data.insights.map((entry: unknown) => String(entry)) : undefined,
nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined, nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined,
quickLinks: Array.isArray(data.quickLinks) ? data.quickLinks as ChatQuickLink[] : undefined, quickLinks: Array.isArray(data.quickLinks) ? data.quickLinks as ChatQuickLink[] : undefined,
timestamp: new Date(), timestamp: new Date(),
@ -437,7 +463,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: Date.now() + 1, id: Date.now() + 1,
role: 'assistant', role: 'assistant',
content: `Error: ${err.message}`, content: formatChatError(err),
timestamp: new Date(), timestamp: new Date(),
}]); }]);
} }
@ -704,6 +730,32 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
</div> </div>
) : null} ) : null}
{msg.insights && msg.insights.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)]">
Key facts
</div>
<div className="flex flex-col gap-1.5">
{msg.insights.map((insight, index) => (
<div
key={`${msg.id}-insight-${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)',
}}
>
{insight}
</div>
))}
</div>
</div>
) : null}
{msg.quickLinks && msg.quickLinks.length > 0 ? ( {msg.quickLinks && msg.quickLinks.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{msg.quickLinks.map((link, index) => ( {msg.quickLinks.map((link, index) => (