feat: AI Desks — configurable agent system for news/technical/eco processing

- New ai_desks table with CRUD (get_all/by_type/upsert/delete)
- ai_desks router: REST API + GET /signal-catalog (7 extensible signals)
- News Desk: semantic dedup via AI (±N days window, system_prompt hint)
- Technical Desk: 4 signal detectors driven by desk config
  (ma_cross, rsi_extreme, bb_squeeze, new_52w_extreme)
- 3 more signals in catalog ready to enable: price_gap, volume_spike, macd_crossover
- market_event_detector.py loads desk configs at runtime, falls back to legacy params
- AIDesks.tsx: full editor UI with signal toggles, param sliders, instrument multi-select
- Sidebar: Bot icon + /ai-desks route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 23:08:14 +02:00
parent 069b398d75
commit 97706dea7b
7 changed files with 1350 additions and 135 deletions

View File

@@ -27,6 +27,7 @@ import ExternalSnapshot from './pages/ExternalSnapshot'
import InstrumentDashboard from './pages/InstrumentDashboard'
import CycleActions from './pages/CycleActions'
import MarketEvents from './pages/MarketEvents'
import AIDesks from './pages/AIDesks'
import { Navigate } from 'react-router-dom'
import { useCycleWatcher } from './hooks/useApi'
@@ -74,6 +75,7 @@ export default function App() {
<Route path="/timeline" element={<Navigate to="/market-events" replace />} />
<Route path="/market-events" element={<MarketEvents />} />
<Route path="/cycle-actions" element={<CycleActions />} />
<Route path="/ai-desks" element={<AIDesks />} />
</Routes>
</main>
</div>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -29,6 +29,7 @@ const nav = [
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Analysis' },
{ to: '/market-events', icon: Radio, label: 'Market Events' },
{ to: '/ai-desks', icon: Bot, label: 'AI Desks' },
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
{ to: '/logs', icon: ScrollText, label: 'System Logs' },

View File

@@ -0,0 +1,599 @@
import { useState, useEffect, useCallback } from 'react'
import { Bot, Plus, Trash2, Save, ChevronDown, ChevronRight, ToggleLeft, ToggleRight } from 'lucide-react'
import clsx from 'clsx'
// ── Types ─────────────────────────────────────────────────────────────────────
interface SignalParam {
type: 'int' | 'float' | 'pairs'
label: string
default: number | number[][]
min?: number
max?: number
options?: string[]
}
interface SignalDef {
id: string
label: string
description: string
params: Record<string, SignalParam>
}
interface AIDesk {
id?: number
name: string
type: 'news' | 'technical' | 'eco' | 'report'
active: boolean
system_prompt: string
instruments: string[]
config: Record<string, any>
}
const ALL_INSTRUMENTS = [
'SPY','QQQ','IWM','EEM','EFA',
'GLD','SLV','USO','UNG','TLT',
'HYG','EURUSD=X','USDJPY=X','GBPUSD=X',
'VXX','AAPL','NVDA','GS','XOM','BTC-USD',
]
const TYPE_LABELS: Record<string, string> = {
news: 'News',
technical: 'Technical',
eco: 'Économique',
report: 'Report',
}
const TYPE_COLORS: Record<string, string> = {
news: 'text-blue-400 bg-blue-900/20 border-blue-700/40',
technical: 'text-cyan-400 bg-cyan-900/20 border-cyan-700/40',
eco: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40',
report: 'text-violet-400 bg-violet-900/20 border-violet-700/40',
}
// ── Subcomponents ─────────────────────────────────────────────────────────────
function InstrumentSelector({
selected,
onChange,
}: {
selected: string[]
onChange: (v: string[]) => void
}) {
const toggle = (t: string) => {
onChange(selected.includes(t) ? selected.filter(x => x !== t) : [...selected, t])
}
const all = selected.length === ALL_INSTRUMENTS.length
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-slate-400">Instruments surveillés</label>
<button
onClick={() => onChange(all ? [] : [...ALL_INSTRUMENTS])}
className="text-xs text-blue-400 hover:text-blue-300"
>
{all ? 'Tout désélectionner' : 'Tout sélectionner'}
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{ALL_INSTRUMENTS.map(t => (
<button
key={t}
onClick={() => toggle(t)}
className={clsx(
'px-2 py-0.5 rounded text-xs border transition-colors',
selected.includes(t)
? 'bg-blue-900/40 border-blue-600/60 text-blue-300'
: 'bg-dark-800 border-slate-700/40 text-slate-500 hover:border-slate-600',
)}
>
{t}
</button>
))}
</div>
</div>
)
}
function SignalToggle({
signal,
value,
onChange,
}: {
signal: SignalDef
value: { enabled: boolean; [k: string]: any }
onChange: (v: { enabled: boolean; [k: string]: any }) => void
}) {
const [open, setOpen] = useState(false)
const update = (key: string, val: any) => {
onChange({ ...value, [key]: val })
}
return (
<div className={clsx(
'rounded-lg border transition-colors',
value.enabled
? 'border-cyan-700/40 bg-cyan-900/10'
: 'border-slate-700/30 bg-dark-800/60',
)}>
<div className="flex items-center gap-3 px-3 py-2.5">
<button
onClick={() => onChange({ ...value, enabled: !value.enabled })}
className="shrink-0"
>
{value.enabled
? <ToggleRight className="w-5 h-5 text-cyan-400" />
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
</button>
<div className="flex-1 min-w-0">
<div className={clsx('text-sm font-medium', value.enabled ? 'text-white' : 'text-slate-500')}>
{signal.label}
</div>
<div className="text-xs text-slate-600 truncate">{signal.description}</div>
</div>
{value.enabled && Object.keys(signal.params).length > 0 && (
<button
onClick={() => setOpen(o => !o)}
className="text-slate-500 hover:text-slate-300"
>
{open
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />}
</button>
)}
</div>
{open && value.enabled && (
<div className="px-4 pb-3 space-y-2 border-t border-slate-700/20 pt-2">
{Object.entries(signal.params).map(([key, p]) => {
if (p.type === 'pairs') {
return (
<div key={key}>
<div className="text-xs text-slate-400 mb-1">{p.label}</div>
<div className="text-xs text-slate-500 italic">
{JSON.stringify(value[key] ?? p.default)}
</div>
</div>
)
}
return (
<div key={key} className="flex items-center gap-3">
<label className="text-xs text-slate-400 w-28 shrink-0">{p.label}</label>
<input
type="number"
min={p.min}
max={p.max}
step={p.type === 'float' ? 0.01 : 1}
value={value[key] ?? p.default}
onChange={e => update(key, p.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value))}
className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white"
/>
</div>
)
})}
</div>
)}
</div>
)
}
function NewsConfig({
config,
onChange,
}: {
config: Record<string, any>
onChange: (c: Record<string, any>) => void
}) {
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Score min d'impact</label>
<input
type="number" min={0} max={1} step={0.05}
value={config.min_impact ?? 0.55}
onChange={e => set('min_impact', parseFloat(e.target.value))}
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Max events à évaluer</label>
<input
type="number" min={1} max={50}
value={config.max_evaluate ?? 15}
onChange={e => set('max_evaluate', parseInt(e.target.value))}
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => set('dedup_enabled', !config.dedup_enabled)}
className="shrink-0"
>
{config.dedup_enabled
? <ToggleRight className="w-5 h-5 text-blue-400" />
: <ToggleLeft className="w-5 h-5 text-slate-600" />}
</button>
<span className="text-sm text-slate-300">Déduplication sémantique</span>
{config.dedup_enabled && (
<div className="flex items-center gap-2 ml-auto">
<span className="text-xs text-slate-500">Fenêtre ± jours</span>
<input
type="number" min={1} max={7}
value={config.dedup_lookback_days ?? 2}
onChange={e => set('dedup_lookback_days', parseInt(e.target.value))}
className="w-14 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white"
/>
</div>
)}
</div>
</div>
)
}
function EcoConfig({
config,
onChange,
}: {
config: Record<string, any>
onChange: (c: Record<string, any>) => void
}) {
const set = (k: string, v: any) => onChange({ ...config, [k]: v })
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Seuil z-score</label>
<input
type="number" min={0.5} max={5} step={0.1}
value={config.z_threshold ?? 1.5}
onChange={e => set('z_threshold', parseFloat(e.target.value))}
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Jours lookback</label>
<input
type="number" min={1} max={30}
value={config.days ?? 7}
onChange={e => set('days', parseInt(e.target.value))}
className="w-full bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
</div>
)
}
// ── Desk editor ───────────────────────────────────────────────────────────────
function DeskEditor({
desk,
catalog,
onSave,
onDelete,
}: {
desk: AIDesk
catalog: SignalDef[]
onSave: (d: AIDesk) => void
onDelete: () => void
}) {
const [d, setD] = useState<AIDesk>(desk)
const set = (k: keyof AIDesk, v: any) => setD(prev => ({ ...prev, [k]: v }))
const dirty = JSON.stringify(d) !== JSON.stringify(desk)
// Ensure signals config is initialized from catalog for technical desks
useEffect(() => {
if (d.type !== 'technical' || !catalog.length) return
const signals = d.config.signals ?? {}
let changed = false
const next = { ...signals }
for (const sig of catalog) {
if (!next[sig.id]) {
const defaults: Record<string, any> = { enabled: false }
for (const [k, p] of Object.entries(sig.params)) {
defaults[k] = p.default
}
next[sig.id] = defaults
changed = true
}
}
if (changed) setD(prev => ({ ...prev, config: { ...prev.config, signals: next } }))
}, [catalog, d.type])
const updateSignal = (sigId: string, val: any) => {
setD(prev => ({
...prev,
config: { ...prev.config, signals: { ...(prev.config.signals ?? {}), [sigId]: val } },
}))
}
return (
<div className="space-y-5">
{/* Header controls */}
<div className="flex items-center gap-3">
<div className="flex-1">
<input
value={d.name}
onChange={e => set('name', e.target.value)}
placeholder="Nom du desk"
className="w-full bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white"
/>
</div>
<select
value={d.type}
onChange={e => set('type', e.target.value as AIDesk['type'])}
className="bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white"
>
{Object.entries(TYPE_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
<button
onClick={() => set('active', !d.active)}
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded border text-xs font-medium transition-colors',
d.active
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-400'
: 'bg-dark-800 border-slate-700/30 text-slate-500',
)}
>
{d.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
{d.active ? 'Actif' : 'Inactif'}
</button>
</div>
{/* System prompt */}
<div>
<label className="text-xs text-slate-400 block mb-1">System prompt</label>
<textarea
value={d.system_prompt}
onChange={e => set('system_prompt', e.target.value)}
rows={4}
placeholder="Instructions spécifiques pour ce desk AI..."
className="w-full bg-dark-900 border border-slate-700/40 rounded px-3 py-2 text-sm text-white resize-y font-mono"
/>
</div>
{/* Instruments */}
<InstrumentSelector
selected={d.instruments}
onChange={v => set('instruments', v)}
/>
{/* Type-specific config */}
<div>
<div className="text-xs text-slate-400 mb-2">Configuration</div>
{d.type === 'news' && (
<NewsConfig
config={d.config}
onChange={c => set('config', c)}
/>
)}
{d.type === 'eco' && (
<EcoConfig
config={d.config}
onChange={c => set('config', c)}
/>
)}
{d.type === 'technical' && catalog.length > 0 && (
<div className="space-y-2">
{catalog.map(sig => (
<SignalToggle
key={sig.id}
signal={sig}
value={d.config.signals?.[sig.id] ?? { enabled: false }}
onChange={v => updateSignal(sig.id, v)}
/>
))}
<div className="pt-1">
<label className="text-xs text-slate-400 block mb-1">Lookback jours</label>
<input
type="number" min={1} max={30}
value={d.config.lookback_days ?? 7}
onChange={e => set('config', { ...d.config, lookback_days: parseInt(e.target.value) })}
className="w-24 bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-sm text-white"
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between pt-2 border-t border-slate-700/30">
<button
onClick={onDelete}
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs text-red-400 hover:bg-red-900/20 border border-transparent hover:border-red-800/40 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</button>
<button
onClick={() => onSave(d)}
disabled={!dirty}
className={clsx(
'flex items-center gap-1.5 px-4 py-1.5 rounded text-sm font-medium transition-colors',
dirty
? 'bg-blue-600 hover:bg-blue-500 text-white'
: 'bg-dark-700 text-slate-600 cursor-not-allowed',
)}
>
<Save className="w-4 h-4" />
Enregistrer
</button>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function AIDesks() {
const [desks, setDesks] = useState<AIDesk[]>([])
const [catalog, setCatalog] = useState<SignalDef[]>([])
const [selected, setSelected] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
const [dRes, cRes] = await Promise.all([
fetch('/api/ai-desks'),
fetch('/api/ai-desks/signal-catalog'),
])
const dData = await dRes.json()
const cData = await cRes.json()
setDesks(dData)
setCatalog(cData)
if (dData.length > 0 && selected === null) setSelected(0)
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
const showMsg = (ok: boolean, text: string) => {
setMsg({ ok, text })
setTimeout(() => setMsg(null), 3000)
}
const handleSave = async (d: AIDesk) => {
setSaving(true)
try {
const method = d.id ? 'PUT' : 'POST'
const url = d.id ? `/api/ai-desks/${d.id}` : '/api/ai-desks'
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(d),
})
if (r.ok) {
showMsg(true, 'Desk enregistré')
await load()
} else {
const err = await r.json()
showMsg(false, err.detail ?? 'Erreur sauvegarde')
}
} finally {
setSaving(false)
}
}
const handleDelete = async (d: AIDesk) => {
if (!d.id) return
if (!confirm(`Supprimer le desk "${d.name}" ?`)) return
const r = await fetch(`/api/ai-desks/${d.id}`, { method: 'DELETE' })
if (r.ok) {
showMsg(true, 'Desk supprimé')
setSelected(null)
await load()
}
}
const handleNew = () => {
const newDesk: AIDesk = {
name: 'Nouveau Desk',
type: 'news',
active: true,
system_prompt: '',
instruments: [],
config: {},
}
setDesks(prev => [...prev, newDesk])
setSelected(desks.length)
}
const currentDesk = selected !== null ? desks[selected] : null
return (
<div className="p-6 max-w-7xl mx-auto space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bot className="w-6 h-6 text-blue-400" />
<div>
<h1 className="text-xl font-bold text-white">AI Desks</h1>
<p className="text-sm text-slate-500">Configurer les agents IA spécialisés</p>
</div>
</div>
<button
onClick={handleNew}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Nouveau desk
</button>
</div>
{msg && (
<div className={clsx(
'px-4 py-2 rounded-lg text-sm border',
msg.ok
? 'bg-emerald-900/30 border-emerald-700/40 text-emerald-300'
: 'bg-red-900/30 border-red-700/40 text-red-300',
)}>
{msg.text}
</div>
)}
{loading ? (
<div className="text-slate-500 text-sm py-8 text-center">Chargement...</div>
) : (
<div className="grid grid-cols-[240px_1fr] gap-4">
{/* Desk list */}
<div className="space-y-1">
{desks.map((d, i) => (
<button
key={d.id ?? `new-${i}`}
onClick={() => setSelected(i)}
className={clsx(
'w-full text-left px-3 py-2.5 rounded-lg border transition-colors',
selected === i
? 'bg-blue-900/30 border-blue-700/40 text-white'
: 'bg-dark-800/60 border-slate-700/30 text-slate-400 hover:text-white hover:border-slate-600/60',
)}
>
<div className="flex items-center gap-2">
<span className={clsx(
'inline-block w-1.5 h-1.5 rounded-full',
d.active ? 'bg-emerald-400' : 'bg-slate-600',
)} />
<span className="text-sm font-medium truncate">{d.name}</span>
</div>
<span className={clsx(
'inline-block mt-1 px-1.5 py-0.5 rounded text-xs border',
TYPE_COLORS[d.type] ?? 'text-slate-400 bg-dark-700 border-slate-700/30',
)}>
{TYPE_LABELS[d.type] ?? d.type}
</span>
</button>
))}
</div>
{/* Editor */}
{currentDesk ? (
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-5">
<DeskEditor
key={currentDesk.id ?? `new-${selected}`}
desk={currentDesk}
catalog={catalog}
onSave={handleSave}
onDelete={() => handleDelete(currentDesk)}
/>
</div>
) : (
<div className="bg-dark-800/60 rounded-xl border border-slate-700/30 flex items-center justify-center text-slate-600 text-sm">
Sélectionner un desk ou en créer un nouveau
</div>
)}
</div>
)}
</div>
)
}