feat(chat): add structured copilot insights
This commit is contained in:
parent
6797452e7b
commit
1a794d2365
@ -363,6 +363,7 @@ interface ChatResponsePayload {
|
||||
profile?: ChatProfilePayload;
|
||||
summary: string;
|
||||
reasoning: string;
|
||||
insights?: string[];
|
||||
nextActions?: string[];
|
||||
quickLinks?: ChatQuickLink[];
|
||||
fallback?: 'local_deterministic';
|
||||
@ -1112,6 +1113,11 @@ export class ApiServer {
|
||||
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)}.`,
|
||||
insights: [
|
||||
`Position side: ${side}`,
|
||||
`Entry price: ${Number(target.entryPrice || 0).toFixed(2)}`,
|
||||
`Current price: ${Number(target.currentPrice || 0).toFixed(2)}`,
|
||||
],
|
||||
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.',
|
||||
@ -1169,6 +1175,11 @@ export class ApiServer {
|
||||
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.',
|
||||
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: [
|
||||
'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.',
|
||||
@ -1202,6 +1213,11 @@ export class ApiServer {
|
||||
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.',
|
||||
insights: [
|
||||
`Execution status: ${executionStatus}`,
|
||||
target.executionCode ? `Execution code: ${target.executionCode}` : null,
|
||||
target.orderId ? `Related order: ${target.orderId}` : null,
|
||||
].filter(Boolean) as string[],
|
||||
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.',
|
||||
@ -1236,6 +1252,11 @@ export class ApiServer {
|
||||
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(' | ')}` : ''}`,
|
||||
insights: [
|
||||
`Missing from exchange: ${missingExchange}`,
|
||||
`Missing in DB: ${missingDb}`,
|
||||
`Auto-closed parity trades: ${autoClosedCount}`,
|
||||
],
|
||||
nextActions: degraded
|
||||
? [
|
||||
'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'
|
||||
? `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.',
|
||||
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: [
|
||||
'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.',
|
||||
@ -1323,6 +1349,11 @@ export class ApiServer {
|
||||
action: 'recommend_trade_plan',
|
||||
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'}.`,
|
||||
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: [
|
||||
'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.',
|
||||
@ -1346,6 +1377,12 @@ export class ApiServer {
|
||||
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}`,
|
||||
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: [
|
||||
'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.',
|
||||
@ -1365,6 +1402,11 @@ export class ApiServer {
|
||||
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}.`,
|
||||
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: [
|
||||
'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.',
|
||||
@ -3223,6 +3265,7 @@ 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),
|
||||
"insights": string[] (optional concise evidence or key facts),
|
||||
"nextActions": string[] (optional safe next steps for the user),
|
||||
"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.
|
||||
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.
|
||||
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.
|
||||
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.`;
|
||||
|
||||
@ -55,13 +55,17 @@ function testChatActionContract() {
|
||||
}
|
||||
|
||||
function testQuickLinkContract() {
|
||||
assertSourceIncludes(
|
||||
`"insights": string[] (optional concise evidence or key facts)`,
|
||||
'chat prompt contract must document structured insights',
|
||||
);
|
||||
assertSourceIncludes(
|
||||
`"quickLinks": [{ kind: "portfolio" | "plans" | "settings", ... }] (optional safe in-app destinations)`,
|
||||
'chat prompt contract must document quickLinks',
|
||||
);
|
||||
assertSourceIncludes(
|
||||
'Include safe "nextActions" and "quickLinks" whenever they would help the user move to the right app surface.',
|
||||
'chat prompt rules must require safe nextActions/quickLinks guidance',
|
||||
'Include safe "insights", "nextActions", and "quickLinks" whenever they would help the user move to the right app surface.',
|
||||
'chat prompt rules must require safe insights/nextActions/quickLinks guidance',
|
||||
);
|
||||
assertSourceIncludes(
|
||||
"{ kind: 'plans', label: 'Manage in Plans'",
|
||||
|
||||
@ -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 waitFor(() => {
|
||||
expect(screen.getByText(/Error: Not authenticated/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/session is not authorized for chat right now/i)).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
}, 15000);
|
||||
|
||||
@ -221,6 +221,7 @@ describe('ChatControl DOM flow', () => {
|
||||
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.',
|
||||
action: 'explain_position',
|
||||
insights: ['Position side: BUY', 'Entry price: 60000.00'],
|
||||
})
|
||||
} as any);
|
||||
|
||||
@ -233,6 +234,8 @@ describe('ChatControl DOM flow', () => {
|
||||
await waitFor(() => {
|
||||
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(/Key facts/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Position side: BUY/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Apply to Dashboard/i })).not.toBeInTheDocument();
|
||||
|
||||
@ -21,6 +21,7 @@ interface ChatMessage {
|
||||
content: string;
|
||||
profileData?: any;
|
||||
action?: ChatAssistantAction;
|
||||
insights?: string[];
|
||||
nextActions?: string[];
|
||||
quickLinks?: ChatQuickLink[];
|
||||
timestamp: Date;
|
||||
@ -36,6 +37,9 @@ type ChatAssistantAction =
|
||||
| 'create_profile'
|
||||
| 'update_profile'
|
||||
| 'recommend_profile_change'
|
||||
| 'recommend_trade_plan'
|
||||
| 'recommend_reconciliation_followup'
|
||||
| 'review_recent_trades'
|
||||
| 'explain'
|
||||
| 'explain_position'
|
||||
| '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: '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: '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: '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' =>
|
||||
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) => ({
|
||||
signalContexts: Object.entries(botState.symbols ?? {})
|
||||
.flatMap(([symbol, symbolState]) =>
|
||||
@ -423,6 +448,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,
|
||||
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,
|
||||
quickLinks: Array.isArray(data.quickLinks) ? data.quickLinks as ChatQuickLink[] : undefined,
|
||||
timestamp: new Date(),
|
||||
@ -437,7 +463,7 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now() + 1,
|
||||
role: 'assistant',
|
||||
content: `Error: ${err.message}`,
|
||||
content: formatChatError(err),
|
||||
timestamp: new Date(),
|
||||
}]);
|
||||
}
|
||||
@ -704,6 +730,32 @@ export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlP
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{msg.quickLinks.map((link, index) => (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user