feat: portfolio context injection + AI call log viewer
Portfolio context (portfolio_context.py): - get_open_trades_with_moves(): fetches open trades + 1d/5d yfinance price moves - get_portfolio_concentration(): counts by asset_class - build_portfolio_context_block(): formatted prompt block with strict AI instructions (no double positions, flag contradictions, avoid overweight classes) AI call logging: - ai_call_logs table in DB (run_id, call_type, system/user prompt, response, tokens, ms) - _chat() now accepts log_meta dict → saves call to DB non-blocking after each call - suggest and score_batch calls pass run_id + call_type for full traceability auto_cycle.py: - Builds portfolio context before snapshot and both AI calls - Context snapshot now includes portfolio_open_positions key SystemLogs.tsx: - "Contexte IA" tab gains sub-tabs: Contexte / Appels IA - AiCallRow: expandable with 3 panes (user prompt / system prompt / response) shows model, tokens breakdown, duration, call type badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -888,6 +888,14 @@ export const useReplayCycle = () =>
|
||||
api.post(`/cycle/contexts/${runId}/replay`, { override_notes: notes ?? null }).then(r => r.data),
|
||||
})
|
||||
|
||||
export const useAiCallLogs = (runId: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ['ai-call-logs', runId],
|
||||
queryFn: () => api.get(`/cycle/ai-calls/${runId}`).then(r => r.data),
|
||||
enabled: !!runId,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// ── IV Watchlist Management ───────────────────────────────────────────────────
|
||||
|
||||
export const useWatchlistTickers = () =>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain, Play, Loader2 } from 'lucide-react'
|
||||
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain, Play, Loader2, Zap, MessageSquare, BarChart2, Clock } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, type LogFilters } from '../hooks/useApi'
|
||||
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, useAiCallLogs, type LogFilters } from '../hooks/useApi'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
@@ -76,9 +76,127 @@ function LogRow({ log }: { log: any }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── AI call type config ──────────────────────────────────────────────────────
|
||||
const AI_CALL_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
suggest: { label: 'Suggestion patterns', color: 'text-purple-300 border-purple-700/40 bg-purple-900/20', icon: <Brain className="w-3 h-3" /> },
|
||||
score_batch: { label: 'Scoring batch', color: 'text-cyan-300 border-cyan-700/40 bg-cyan-900/20', icon: <BarChart2 className="w-3 h-3" /> },
|
||||
unknown: { label: 'Appel IA', color: 'text-slate-300 border-slate-700/40 bg-slate-800', icon: <Zap className="w-3 h-3" /> },
|
||||
}
|
||||
|
||||
function AiCallRow({ call }: { call: any }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activePane, setActivePane] = useState<'user' | 'system' | 'response'>('user')
|
||||
const cfg = AI_CALL_CONFIG[call.call_type] ?? AI_CALL_CONFIG.unknown
|
||||
const ts = (() => { try { return format(new Date(call.called_at), 'HH:mm:ss', { locale: fr }) } catch { return call.called_at } })()
|
||||
const totalTokens = (call.tokens_prompt ?? 0) + (call.tokens_completion ?? 0)
|
||||
|
||||
const responseStr = useMemo(() => {
|
||||
if (!call.response) return ''
|
||||
try { return JSON.stringify(call.response, null, 2) } catch { return String(call.response) }
|
||||
}, [call.response])
|
||||
|
||||
return (
|
||||
<div className="border border-slate-800 rounded overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-slate-800/50 transition-colors text-left"
|
||||
>
|
||||
<span className={clsx('inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-[10px] font-semibold shrink-0', cfg.color)}>
|
||||
{cfg.icon} {cfg.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono shrink-0">{ts}</span>
|
||||
{call.pattern_name && (
|
||||
<span className="text-[10px] text-slate-400 truncate">— {call.pattern_name}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||
{totalTokens > 0 && (
|
||||
<span className="text-[10px] text-slate-600 font-mono">{totalTokens.toLocaleString()} tokens</span>
|
||||
)}
|
||||
{call.duration_ms > 0 && (
|
||||
<span className="text-[10px] text-slate-600 font-mono flex items-center gap-0.5">
|
||||
<Clock className="w-2.5 h-2.5" />{(call.duration_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
{expanded ? <ChevronDown className="w-3.5 h-3.5 text-slate-500" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-500" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-800">
|
||||
{/* Token breakdown */}
|
||||
{totalTokens > 0 && (
|
||||
<div className="flex gap-4 px-3 py-2 bg-slate-900/50 text-[10px] font-mono text-slate-500 border-b border-slate-800">
|
||||
<span>Modèle: <span className="text-slate-300">{call.model || 'gpt-4o'}</span></span>
|
||||
<span>Prompt: <span className="text-amber-300">{(call.tokens_prompt ?? 0).toLocaleString()} tok</span></span>
|
||||
<span>Completion: <span className="text-green-300">{(call.tokens_completion ?? 0).toLocaleString()} tok</span></span>
|
||||
<span>Total: <span className="text-white">{totalTokens.toLocaleString()} tok</span></span>
|
||||
<span>Durée: <span className="text-purple-300">{(call.duration_ms / 1000).toFixed(2)}s</span></span>
|
||||
</div>
|
||||
)}
|
||||
{/* Pane selector */}
|
||||
<div className="flex gap-0 border-b border-slate-800">
|
||||
{([
|
||||
{ key: 'user' as const, label: 'Prompt utilisateur', icon: <MessageSquare className="w-3 h-3" /> },
|
||||
{ key: 'system' as const, label: 'Prompt système', icon: <Brain className="w-3 h-3" /> },
|
||||
{ key: 'response' as const, label: 'Réponse IA', icon: <Zap className="w-3 h-3" /> },
|
||||
]).map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => setActivePane(p.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 px-3 py-1.5 text-[10px] font-medium border-b-2 -mb-px transition-colors',
|
||||
activePane === p.key
|
||||
? 'border-purple-500 text-purple-300'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{p.icon} {p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="p-3 bg-black/40 max-h-[500px] overflow-y-auto">
|
||||
{activePane === 'user' && (
|
||||
<pre className="text-[10px] font-mono text-slate-300 whitespace-pre-wrap leading-relaxed">{call.user_prompt || '(vide)'}</pre>
|
||||
)}
|
||||
{activePane === 'system' && (
|
||||
<pre className="text-[10px] font-mono text-slate-400 whitespace-pre-wrap leading-relaxed">{call.system_prompt || '(vide)'}</pre>
|
||||
)}
|
||||
{activePane === 'response' && (
|
||||
<pre className="text-[10px] font-mono text-green-300 whitespace-pre-wrap leading-relaxed">{responseStr || '(pas de réponse)'}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiCallsSection({ runId }: { runId: string }) {
|
||||
const { data, isLoading } = useAiCallLogs(runId)
|
||||
const calls: any[] = data?.calls ?? []
|
||||
|
||||
if (isLoading) return <div className="p-4 text-xs text-slate-500">Chargement des appels IA…</div>
|
||||
if (calls.length === 0) return (
|
||||
<div className="p-4 text-xs text-slate-600 text-center">
|
||||
Aucun appel IA enregistré pour ce cycle — les logs seront disponibles dès le prochain cycle.
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] text-slate-500 px-1 font-mono">
|
||||
{calls.length} appel(s) IA — cliquer pour voir prompts + réponse complète
|
||||
</div>
|
||||
{calls.map(c => <AiCallRow key={c.id} call={c} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextTab() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
||||
const [replayNotes, setReplayNotes] = useState('')
|
||||
const [rightPane, setRightPane] = useState<'context' | 'calls'>('context')
|
||||
const [replayResult, setReplayResult] = useState<any>(null)
|
||||
const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30)
|
||||
const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId)
|
||||
@@ -113,7 +231,7 @@ function ContextTab() {
|
||||
{snapshots.map(s => (
|
||||
<button
|
||||
key={s.run_id}
|
||||
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null) }}
|
||||
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null); setRightPane('context') }}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2.5 hover:bg-slate-800/60 transition-colors',
|
||||
selectedRunId === s.run_id && 'bg-purple-900/30 border-l-2 border-purple-500'
|
||||
@@ -191,12 +309,40 @@ function ContextTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context sections */}
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
||||
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
|
||||
<ContextSection key={key} label={key} value={val} />
|
||||
{/* Sub-tabs: Contexte / Appels IA */}
|
||||
<div className="flex gap-0 border-b border-slate-800 bg-slate-900/40 shrink-0">
|
||||
{([
|
||||
{ key: 'context' as const, label: 'Contexte IA', icon: <Brain className="w-3 h-3" /> },
|
||||
{ key: 'calls' as const, label: 'Appels IA', icon: <Zap className="w-3 h-3" /> },
|
||||
]).map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setRightPane(t.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 -mb-px transition-colors',
|
||||
rightPane === t.key
|
||||
? 'border-purple-500 text-purple-300'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rightPane === 'context' && (
|
||||
<div className="overflow-y-auto flex-1 p-3 space-y-2">
|
||||
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
|
||||
<ContextSection key={key} label={key} value={val} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPane === 'calls' && (
|
||||
<div className="overflow-y-auto flex-1 p-3">
|
||||
<AiCallsSection runId={selectedRunId} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user