Initial commit — GeoOptions Intelligence Cockpit v2.0

Stack: FastAPI + React/TypeScript + SQLite + GPT-4o
Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM,
Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA.
Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-16 20:29:59 +02:00
commit d256b65d30
69 changed files with 18301 additions and 0 deletions

View File

@@ -0,0 +1,690 @@
import { useState, useMemo } from 'react'
import { useAllPatterns, useSavePattern, useDeletePattern, useEvaluatePattern, useSuggestPattern, useAiStatus, useSuggestNewPatterns, useTogglePattern, usePatternSimilarity, useLastScores } from '../hooks/useApi'
import clsx from 'clsx'
import { Zap, Plus, Trash2, Edit3, Brain, Save, RotateCcw, Sparkles, X, Check, Eye, EyeOff } from 'lucide-react'
function jaccard(a: string[], b: string[]): number {
if (!a.length && !b.length) return 0
const setA = new Set(a.map(x => x.toLowerCase()))
const setB = new Set(b.map(x => x.toLowerCase()))
let intersection = 0
for (const x of setA) if (setB.has(x)) intersection++
const union = setA.size + setB.size - intersection
return union === 0 ? 0 : intersection / union
}
const TRIGGERS = ['military', 'sanctions', 'elections', 'natural_disaster', 'health_crisis', 'resource_scarcity', 'trade_war', 'energy', 'political_speech', 'financial_crisis']
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex', 'rates']
const TRIGGER_LABELS: Record<string, string> = {
military: '⚔️ Militaire', sanctions: '🚫 Sanctions', elections: '🗳️ Élections',
natural_disaster: '🌪️ Catastrophe', health_crisis: '🏥 Santé',
resource_scarcity: '⚠️ Ressources', trade_war: '🤝 Commerce',
energy: '⚡ Énergie', political_speech: '🎙️ Discours', financial_crisis: '💸 Finance',
}
const EMPTY_PATTERN = {
name: '', description: '', triggers: [] as string[], keywords: [] as string[],
historical_instances: [] as any[], suggested_trades: [] as any[],
asset_class: 'energy', expected_move_pct: 10, probability: 0.6, horizon_days: 30,
}
function QualityBadge({ score }: { score: number }) {
const color = score >= 75 ? 'badge-green' : score >= 50 ? 'badge-yellow' : 'badge-red'
const label = score >= 75 ? 'Excellent' : score >= 50 ? 'Bon' : 'Faible'
return <span className={clsx('badge', color)}>{score}/100 {label}</span>
}
function PatternCard({ p, onEdit, onDelete, onToggle, similarTo, aiScore }: {
p: any; onEdit: () => void; onDelete: () => void; onToggle: () => void
similarTo?: Array<{ name: string; similarity: number }>
aiScore?: number | null
}) {
const [expanded, setExpanded] = useState(false)
const isCustom = p.source === 'custom'
const isActive = p.is_active !== 0
return (
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
'border-blue-500/30': isCustom,
'opacity-50': !isActive,
})}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0 mr-2">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<div className="text-sm font-semibold text-white">{p.name}</div>
{isCustom && <span className="badge badge-purple text-xs">Custom</span>}
{!isActive && <span className="badge badge-red text-xs">Désactivé</span>}
{p.ai_quality_score && <QualityBadge score={p.ai_quality_score} />}
</div>
<div className="text-xs text-slate-500">{p.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="text-right">
<div className={clsx('text-sm font-bold', p.expected_move_pct > 0 ? 'positive' : 'negative')}>
{p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%
</div>
<div className="text-xs text-slate-600">{p.horizon_days}j</div>
</div>
<button onClick={onEdit} title="Modifier" className="text-slate-500 hover:text-blue-400 transition-colors">
<Edit3 className="w-4 h-4" />
</button>
<button onClick={onToggle} title={isActive ? 'Désactiver' : 'Activer'} className={clsx('transition-colors', isActive ? 'text-slate-500 hover:text-yellow-400' : 'text-yellow-500 hover:text-yellow-300')}>
{isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button onClick={onDelete} title="Supprimer" className="text-slate-500 hover:text-red-400 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{similarTo && similarTo.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similarTo.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mb-2">
{(p.triggers || []).map((t: string) => (
<span key={t} className="badge badge-orange text-xs">{TRIGGER_LABELS[t] ?? t}</span>
))}
<span className="badge badge-blue text-xs">{p.asset_class}</span>
{aiScore != null ? (
<span className={clsx('badge text-xs font-bold', aiScore >= 50 ? 'badge-green' : aiScore >= 25 ? 'badge-yellow' : 'badge-red')}>
{aiScore}/100 IA
</span>
) : (
<span className="badge badge-purple text-xs">{Math.round(p.probability * 100)}% prob.</span>
)}
</div>
<button onClick={() => setExpanded(!expanded)} className="text-xs text-slate-600 hover:text-slate-400">
{expanded ? '▲ Réduire' : '▼ Voir détails'}
</button>
{expanded && (
<div className="mt-3 space-y-2">
{p.keywords?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Mots-clés de détection</div>
<div className="flex flex-wrap gap-1">
{p.keywords.map((kw: string) => (
<span key={kw} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded">#{kw}</span>
))}
</div>
</div>
)}
{p.historical_instances?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Instances historiques</div>
{p.historical_instances.map((h: any, i: number) => (
<div key={i} className="card-sm mb-1">
<span className="text-xs text-slate-500">{h.date}</span>
<span className="text-xs text-slate-300 ml-2">{h.event || h.outcome}</span>
</div>
))}
</div>
)}
{p.suggested_trades?.length > 0 && (
<div>
<div className="text-xs text-slate-600 mb-1">Trades suggérés</div>
{p.suggested_trades.map((t: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs mb-1 flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
{t.asset_class && <span className="badge badge-blue">{t.asset_class}</span>}
<span className="text-slate-500"> {t.rationale}</span>
</div>
))}
</div>
)}
{p.ai_evaluation && Object.keys(p.ai_evaluation).length > 0 && (
<div className="card-sm border-blue-700/30">
<div className="text-xs text-blue-400 font-semibold mb-1">Évaluation IA</div>
{p.ai_evaluation.strengths?.length > 0 && (
<div className="text-xs text-emerald-400 mb-1"> {p.ai_evaluation.strengths[0]}</div>
)}
{p.ai_evaluation.weaknesses?.length > 0 && (
<div className="text-xs text-red-400"> {p.ai_evaluation.weaknesses[0]}</div>
)}
{p.ai_evaluation.overall_recommendation && (
<div className="text-xs text-slate-400 mt-1">{p.ai_evaluation.overall_recommendation}</div>
)}
</div>
)}
</div>
)}
</div>
)
}
function AiSuggestModal({ onClose, onSaveAll, allPatterns }: { onClose: () => void; onSaveAll: (patterns: any[]) => void; allPatterns: any[] }) {
const { mutate: suggest, isPending, data } = useSuggestNewPatterns()
const [saved, setSaved] = useState<Set<number>>(new Set())
const { mutate: savePattern } = useSavePattern()
const suggestions: any[] = (data as any)?.suggested_patterns ?? []
const handleSave = (p: any, idx: number) => {
savePattern(p, { onSuccess: () => setSaved(prev => new Set(prev).add(idx)) })
}
// For each suggestion, find existing patterns with keyword overlap >= 25%
const suggestionSimilarities = useMemo(() =>
suggestions.map(s => {
const kws = s.keywords ?? []
return allPatterns
.map(ep => ({ name: ep.name, similarity: jaccard(kws, ep.keywords ?? []) }))
.filter(x => x.similarity >= 0.25)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3)
}),
[suggestions, allPatterns]
)
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/30">
<div>
<h2 className="text-base font-semibold text-white flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-400" /> Suggérer des patterns par l'IA
</h2>
<p className="text-xs text-slate-500 mt-0.5">
GPT-4o analyse l'actualité géo, les prix, le calendrier et le <span className="text-blue-400">régime macro actuel</span> pour proposer des patterns cohérents avec le scénario dominant
</p>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white"><X className="w-5 h-5" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{!data && !isPending && (
<div className="text-center py-12">
<Sparkles className="w-10 h-10 text-blue-400/40 mx-auto mb-3" />
<p className="text-sm text-slate-400 mb-4">
L'IA va analyser les news géopolitiques actuelles, les mouvements de prix et le calendrier économique pour proposer des patterns pertinents <em>aujourd'hui</em>.
</p>
<button onClick={() => suggest()}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded text-sm font-semibold">
Lancer l'analyse (GPT-4o)
</button>
</div>
)}
{isPending && (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-slate-400">GPT-4o analyse le contexte du moment…</p>
</div>
)}
{suggestions.map((p: any, idx: number) => {
const score = Math.round((p.probability ?? 0.5) * 100)
const similars = suggestionSimilarities[idx] ?? []
return (
<div key={idx} className={clsx('card', saved.has(idx) ? 'border-emerald-700/40 opacity-60' : similars.length > 0 ? 'border-amber-700/30' : 'border-blue-700/20')}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white">{p.name}</div>
<div className="flex gap-1 mt-0.5 flex-wrap">
<span className="badge badge-blue text-xs">{p.asset_class}</span>
<span className={clsx('badge text-xs font-bold', score >= 50 ? 'badge-green' : score >= 25 ? 'badge-yellow' : 'badge-red')}>
{score}/100 IA
</span>
<span className="text-xs text-slate-500">{p.horizon_days}j · {p.expected_move_pct > 0 ? '+' : ''}{p.expected_move_pct}%</span>
</div>
</div>
<button
onClick={() => handleSave(p, idx)}
disabled={saved.has(idx)}
className={clsx('shrink-0 ml-3 flex items-center gap-1 px-3 py-1.5 rounded text-xs font-semibold transition-all', {
'bg-emerald-700/30 border border-emerald-700/40 text-emerald-400 cursor-default': saved.has(idx),
'bg-blue-600 hover:bg-blue-500 text-white': !saved.has(idx),
})}>
{saved.has(idx) ? <><Check className="w-3 h-3" /> Sauvegardé</> : <><Save className="w-3 h-3" /> Sauvegarder</>}
</button>
</div>
{similars.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{similars.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1 text-[10px] bg-amber-900/30 border border-amber-600/30 text-amber-400 rounded px-1.5 py-0.5">
⚠ Similaire à <span className="font-semibold max-w-[140px] truncate">{s.name}</span>
<span className="font-mono">{Math.round(s.similarity * 100)}%</span>
</span>
))}
</div>
)}
<p className="text-xs text-slate-400 mb-2">{p.description}</p>
{p.macro_fit && (
<div className="flex items-start gap-1.5 mb-2 text-[11px] text-blue-300/80 bg-blue-900/20 border border-blue-700/20 rounded px-2 py-1.5">
<span className="shrink-0 mt-px">🌐</span>
<span>{p.macro_fit}</span>
</div>
)}
{p.suggested_trades?.length > 0 && (
<div className="space-y-1">
{p.suggested_trades.map((t: any, ti: number) => (
<div key={ti} className="flex items-center gap-2 text-xs flex-wrap">
<span className="badge badge-green">{t.strategy}</span>
<span className="text-white font-mono">{t.underlying}</span>
<span className="text-slate-500">— {t.rationale}</span>
</div>
))}
</div>
)}
</div>
)
})}
{data && suggestions.length === 0 && (
<div className="card text-center py-8 text-slate-500 text-sm">
Aucun pattern suggéré. Réessayer.
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-700/30 flex justify-between items-center">
{suggestions.length > 0 && (
<span className="text-xs text-slate-500">
{saved.size}/{suggestions.length} sauvegardé{saved.size > 1 ? 's' : ''}
</span>
)}
<div className="flex gap-2 ml-auto">
{data && suggestions.length > 0 && (
<button onClick={() => suggest()} disabled={isPending}
className="flex items-center gap-1 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-xs">
<RotateCcw className="w-3 h-3" /> Régénérer
</button>
)}
<button onClick={onClose} className="border border-slate-700 text-slate-400 hover:text-slate-200 px-4 py-1.5 rounded text-xs">
Fermer
</button>
</div>
</div>
</div>
</div>
)
}
function PatternForm({ initial, onSave, onCancel }: { initial?: any; onSave: (p: any) => void; onCancel: () => void }) {
const [form, setForm] = useState(initial ? {
...initial,
triggers: Array.isArray(initial.triggers) ? initial.triggers : [],
keywords: Array.isArray(initial.keywords) ? initial.keywords : [],
} : { ...EMPTY_PATTERN })
const [kwInput, setKwInput] = useState('')
const [aiResult, setAiResult] = useState<any>(null)
const [aiLoading, setAiLoading] = useState(false)
const [suggestInput, setSuggestInput] = useState('')
const [suggestLoading, setSuggestLoading] = useState(false)
const { mutateAsync: evaluate } = useEvaluatePattern()
const { mutateAsync: suggest } = useSuggestPattern()
const { data: aiStatus } = useAiStatus()
const set = (k: string, v: unknown) => setForm((f: any) => ({ ...f, [k]: v }))
const toggleTrigger = (t: string) => {
set('triggers', form.triggers.includes(t) ? form.triggers.filter((x: string) => x !== t) : [...form.triggers, t])
}
const addKeyword = () => {
if (kwInput.trim() && !form.keywords.includes(kwInput.trim())) {
set('keywords', [...form.keywords, kwInput.trim()])
setKwInput('')
}
}
const evaluateWithAI = async () => {
setAiLoading(true)
try {
const result = await evaluate(form)
setAiResult(result)
if (result.quality_score) set('ai_quality_score', result.quality_score)
set('ai_evaluation', result)
} catch (e) { console.error(e) }
setAiLoading(false)
}
const suggestFromContext = async () => {
setSuggestLoading(true)
try {
const result = await suggest(suggestInput)
if (result && !result.error) {
setForm((f: any) => ({
...f,
name: result.name || f.name,
description: result.description || f.description,
triggers: result.triggers || f.triggers,
keywords: result.keywords || f.keywords,
historical_instances: result.historical_instances || f.historical_instances,
suggested_trades: result.suggested_trades || f.suggested_trades,
asset_class: result.asset_class || f.asset_class,
expected_move_pct: result.expected_move_pct ?? f.expected_move_pct,
probability: result.probability ?? f.probability,
horizon_days: result.horizon_days ?? f.horizon_days,
}))
}
} catch (e) { console.error(e) }
setSuggestLoading(false)
}
return (
<div className="space-y-4">
{/* AI Suggest from context */}
{aiStatus?.enabled && (
<div className="card border-blue-500/30">
<div className="section-title flex items-center gap-1"><Brain className="w-3 h-3 text-blue-400" /> Générer depuis un contexte (IA)</div>
<div className="flex gap-2">
<textarea
value={suggestInput}
onChange={e => setSuggestInput(e.target.value)}
placeholder="Décris le contexte géopolitique... ex: 'Quand la Chine impose des restrictions sur les terres rares, que se passe-t-il sur le marché des semi-conducteurs et des métaux ?'"
rows={2}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none"
/>
<button onClick={suggestFromContext} disabled={suggestLoading || !suggestInput}
className="shrink-0 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-3 rounded text-xs">
{suggestLoading ? '...' : ' Générer'}
</button>
</div>
</div>
)}
{/* Form fields */}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Nom du pattern</label>
<input value={form.name} onChange={e => set('name', e.target.value)}
placeholder="Ex: Chine tensions Taiwan → Semi-conducteurs spike"
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div className="col-span-2">
<label className="text-xs text-slate-500 mb-1 block">Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
rows={2} placeholder="Mécanisme géopolitique → impact marché..."
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500 resize-none" />
</div>
</div>
{/* Triggers */}
<div>
<label className="text-xs text-slate-500 mb-2 block">Déclencheurs</label>
<div className="flex flex-wrap gap-1.5">
{TRIGGERS.map(t => (
<button key={t} onClick={() => toggleTrigger(t)}
className={clsx('px-2 py-1 rounded border text-xs transition-all', {
'bg-orange-900/30 border-orange-700/50 text-orange-300': form.triggers.includes(t),
'border-slate-700/40 text-slate-500 hover:border-slate-500': !form.triggers.includes(t),
})}>
{TRIGGER_LABELS[t]}
</button>
))}
</div>
</div>
{/* Keywords */}
<div>
<label className="text-xs text-slate-500 mb-1 block">Mots-clés de détection (anglais)</label>
<div className="flex gap-2 mb-2">
<input value={kwInput} onChange={e => setKwInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addKeyword()}
placeholder="Ajouter un mot-clé..."
className="flex-1 bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
<button onClick={addKeyword} className="bg-dark-600 border border-slate-700 hover:border-slate-500 text-slate-300 px-3 rounded text-xs">+</button>
</div>
<div className="flex flex-wrap gap-1">
{form.keywords.map((kw: string) => (
<span key={kw} className="flex items-center gap-1 text-xs bg-dark-700 text-slate-300 px-2 py-0.5 rounded border border-slate-700/40">
#{kw}
<button onClick={() => set('keywords', form.keywords.filter((k: string) => k !== kw))}
className="text-slate-600 hover:text-red-400 ml-0.5">×</button>
</span>
))}
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Classe d'actif</label>
<select value={form.asset_class} onChange={e => set('asset_class', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
{ASSET_CLASSES.map(a => <option key={a}>{a}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Move attendu (%)</label>
<input type="number" value={form.expected_move_pct} onChange={e => set('expected_move_pct', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Probabilité (0-1)</label>
<input type="number" step="0.05" min="0" max="1" value={form.probability} onChange={e => set('probability', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Horizon (jours)</label>
<input type="number" value={form.horizon_days} onChange={e => set('horizon_days', Number(e.target.value))}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500" />
</div>
</div>
{/* AI Evaluation result */}
{aiResult && (
<div className="card border-blue-700/40">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-blue-400 flex items-center gap-1">
<Brain className="w-4 h-4" /> Évaluation IA
</div>
<QualityBadge score={aiResult.quality_score || 0} />
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-slate-500 mb-1 font-semibold"> Points forts</div>
{aiResult.strengths?.map((s: string, i: number) => (
<div key={i} className="text-emerald-400 mb-0.5"> {s}</div>
))}
</div>
<div>
<div className="text-slate-500 mb-1 font-semibold"> Faiblesses</div>
{aiResult.weaknesses?.map((w: string, i: number) => (
<div key={i} className="text-orange-400 mb-0.5"> {w}</div>
))}
</div>
</div>
{aiResult.suggested_improvements?.additional_keywords?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Mots-clés suggérés par l'IA</div>
<div className="flex flex-wrap gap-1">
{aiResult.suggested_improvements.additional_keywords.map((kw: string) => (
<button key={kw} onClick={() => set('keywords', [...form.keywords, kw])}
className="text-xs bg-blue-900/30 border border-blue-700/40 text-blue-300 px-1.5 py-0.5 rounded hover:bg-blue-800/40">
+{kw}
</button>
))}
</div>
</div>
)}
{aiResult.overall_recommendation && (
<div className="mt-2 text-xs text-slate-400 border-t border-slate-700/40 pt-2">
{aiResult.overall_recommendation}
</div>
)}
{aiResult.counter_scenarios?.length > 0 && (
<div className="mt-2">
<div className="text-xs text-slate-500 mb-1">Scénarios d'invalidation</div>
{aiResult.counter_scenarios.map((s: string, i: number) => (
<div key={i} className="text-xs text-red-400 mb-0.5"> {s}</div>
))}
</div>
)}
</div>
)}
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={evaluateWithAI} disabled={aiLoading || !form.name}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm transition-colors disabled:opacity-40">
<Brain className="w-3.5 h-3.5" />
{aiLoading ? 'Évaluation IA...' : 'Évaluer avec IA'}
</button>
)}
<button onClick={onCancel} className="flex items-center gap-1.5 border border-slate-700 text-slate-400 hover:text-slate-200 px-3 py-1.5 rounded text-sm">
<RotateCcw className="w-3.5 h-3.5" /> Annuler
</button>
<button onClick={() => onSave(form)} disabled={!form.name || form.triggers.length === 0}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-1.5 rounded text-sm font-semibold ml-auto">
<Save className="w-3.5 h-3.5" /> Sauvegarder le pattern
</button>
</div>
</div>
)
}
export default function PatternEditor() {
const { data: patterns, isLoading } = useAllPatterns()
const { mutate: savePattern } = useSavePattern()
const { mutate: deletePattern } = useDeletePattern()
const { mutate: togglePattern } = useTogglePattern()
const { data: aiStatus } = useAiStatus()
const { data: simData } = usePatternSimilarity()
const { data: lastScoresData } = useLastScores()
const [editing, setEditing] = useState<any>(null)
const [creating, setCreating] = useState(false)
const [showAiSuggest, setShowAiSuggest] = useState(false)
const [tab, setTab] = useState<'all' | 'custom' | 'builtin'>('all')
// Build AI score map: patternId → best effective score (same logic as cockpit panel)
const aiScoreMap = useMemo(() => {
const map: Record<string, number> = {}
for (const sp of (lastScoresData as any)?.scored_patterns ?? []) {
const pid = sp.pattern_id
if (!pid) continue
const base = sp.score ?? 0
const best = Math.max(base, ...((sp.trade_rankings ?? []) as any[]).map((r: any) =>
Math.max(0, Math.min(100, base + (r.score_delta ?? 0)))))
map[pid] = best
}
return map
}, [lastScoresData])
// Build a map: patternId → [{name, similarity}] for each side of a similar pair
const similarityMap = useMemo(() => {
const pairs: any[] = (simData as any)?.pairs ?? []
const map: Record<string, Array<{ name: string; similarity: number }>> = {}
for (const pair of pairs) {
if (!map[pair.id_a]) map[pair.id_a] = []
map[pair.id_a].push({ name: pair.name_b, similarity: pair.similarity })
if (!map[pair.id_b]) map[pair.id_b] = []
map[pair.id_b].push({ name: pair.name_a, similarity: pair.similarity })
}
return map
}, [simData])
const displayPatterns = (patterns ?? []).filter((p: any) => {
if (tab === 'custom') return p.source === 'custom'
if (tab === 'builtin') return p.source === 'builtin'
return true
})
const handleSave = (form: any) => {
savePattern(form, { onSuccess: () => { setEditing(null); setCreating(false) } })
}
return (
<div className="p-6 space-y-5">
{showAiSuggest && (
<AiSuggestModal
onClose={() => setShowAiSuggest(false)}
onSaveAll={() => setShowAiSuggest(false)}
allPatterns={patterns ?? []}
/>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-400" /> Patterns Géopolitiques
</h1>
<p className="text-xs text-slate-500 mt-0.5">
{patterns?.length ?? 0} patterns actifs ·{' '}
{patterns?.filter((p: any) => p.source === 'custom').length ?? 0} personnalisés
</p>
</div>
<div className="flex gap-2">
{aiStatus?.enabled && (
<button onClick={() => setShowAiSuggest(true)}
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 px-3 py-1.5 rounded text-sm font-semibold transition-colors">
<Sparkles className="w-4 h-4" /> Suggérer par l'IA
</button>
)}
<button onClick={() => { setCreating(true); setEditing(null) }}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded text-sm font-semibold">
<Plus className="w-4 h-4" /> Créer un pattern
</button>
</div>
</div>
{(creating || editing) && (
<div className="card border-blue-500/30">
<div className="text-sm font-semibold text-white mb-4">
{editing ? `Modifier: ${editing.name}` : 'Nouveau pattern géopolitique'}
</div>
<PatternForm
initial={editing}
onSave={handleSave}
onCancel={() => { setEditing(null); setCreating(false) }}
/>
</div>
)}
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
{[
{ key: 'all', label: `Tous (${patterns?.length ?? 0})` },
{ key: 'builtin', label: `Intégrés (${patterns?.filter((p: any) => p.source === 'builtin').length ?? 0})` },
{ key: 'custom', label: `Mes patterns (${patterns?.filter((p: any) => p.source === 'custom').length ?? 0})` },
].map(({ key, label }) => (
<button key={key} onClick={() => setTab(key as any)}
className={clsx('px-3 py-1.5 rounded text-sm', {
'bg-blue-600 text-white': tab === key,
'text-slate-400 hover:text-slate-200': tab !== key,
})}>
{label}
</button>
))}
</div>
{isLoading ? (
<div className="space-y-3">{[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700" />)}</div>
) : (
<div className="space-y-3">
{displayPatterns.map((p: any) => (
<PatternCard key={p.id}
p={p}
onEdit={() => { setEditing(p); setCreating(false) }}
onDelete={() => deletePattern(p.id)}
onToggle={() => togglePattern(p.id)}
similarTo={similarityMap[p.id]}
aiScore={aiScoreMap[p.id] ?? null}
/>
))}
{displayPatterns.length === 0 && (
<div className="card text-center py-10 text-slate-500">
<Zap className="w-8 h-8 mx-auto mb-2 opacity-20" />
<div>Aucun pattern personnalisé</div>
<div className="text-xs mt-1">Créer un pattern avec l'aide de l'IA</div>
</div>
)}
</div>
)}
</div>
)
}