edtech / apps /admin /src /pages /TrainingLab.tsx
CognxSafeTrack
feat: Genspark-Standard upgrade, MLOps audit fixes, and XAMLÉ branding
eac938a
import { useState, useEffect } from 'react';
import { useAuth } from '../App';
import { Upload, Database, Save, Activity, CheckCircle, AlertTriangle, RefreshCw, Lightbulb } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
interface TrainingData {
id: string;
audioUrl: string;
transcription: string; // generated by Whisper
manualCorrection?: string;
rawWER?: number;
normalizedWER?: number;
status: string;
createdAt: string;
}
export default function TrainingLab() {
const { apiKey, logout } = useAuth();
const [mode, setMode] = useState<'db' | 'upload' | 'suggestions'>('db');
const [audios, setAudios] = useState<TrainingData[]>([]);
const [selectedAudio, setSelectedAudio] = useState<TrainingData | null>(null);
const [manualCorrection, setManualCorrection] = useState('');
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ rawWER: number, normalizedWER: number, missingWords: string[] } | null>(null);
const [suggestions, setSuggestions] = useState<{ original: string, replacement: string, count: number }[]>([]);
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set());
const [recalculating, setRecalculating] = useState(false);
const [recalcResult, setRecalcResult] = useState<{ processed: number, avgRawWER: number, avgNormalizedWER: number, improvementPercent: number } | null>(null);
const fetchAudios = async () => {
setLoading(true);
try {
const res = await fetch(`${API_URL}/v1/admin/training/audios`, {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (res.status === 401) return logout();
const data = await res.json();
setAudios(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (mode === 'db') {
fetchAudios();
}
}, [mode, apiKey, logout]);
const handleSubmit = async () => {
if (!selectedAudio || !manualCorrection.trim()) return;
setSubmitting(true);
setResult(null);
try {
const res = await fetch(`${API_URL}/v1/admin/training/submit`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: selectedAudio.id,
audioUrl: selectedAudio.audioUrl,
transcription: selectedAudio.transcription,
manualCorrection
})
});
if (res.status === 401) return logout();
const json = await res.json();
if (json.error) {
alert('Erreur: ' + JSON.stringify(json.error));
} else {
setResult({
rawWER: json.rawWER,
normalizedWER: json.normalizedWER,
missingWords: json.missingWords
});
// Remove from list
setAudios(prev => prev.filter(a => a.id !== selectedAudio.id));
}
} catch (err) {
console.error(err);
alert('Erreur serveur.');
} finally {
setSubmitting(false);
}
};
const fetchSuggestions = async () => {
setLoading(true);
try {
const res = await fetch(`${API_URL}/v1/admin/training/suggestions`, {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (res.status === 401) return logout();
const data = await res.json();
setSuggestions(data);
setSelectedSuggestions(new Set(data.map((d: any) => `${d.original}->${d.replacement}`)));
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const applySuggestions = async () => {
const payload = suggestions.filter(s => selectedSuggestions.has(`${s.original}->${s.replacement}`));
if (payload.length === 0) return;
setSubmitting(true);
try {
const res = await fetch(`${API_URL}/v1/admin/training/apply-suggestions`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ suggestions: payload })
});
if (res.status === 401) return logout();
const json = await res.json();
alert(`Succès! ${json.injectedCount} règles ont été injectées dans le dictionnaire.`);
fetchSuggestions();
} catch (err) {
console.error(err);
} finally {
setSubmitting(false);
}
};
const recalculateWER = async () => {
setRecalculating(true);
try {
const res = await fetch(`${API_URL}/v1/admin/training/recalculate-wer`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (res.status === 401) return logout();
const json = await res.json();
setRecalcResult(json);
} catch (err) {
console.error(err);
} finally {
setRecalculating(false);
}
};
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<Activity className="w-8 h-8 text-purple-600" />
<h1 className="text-3xl font-bold text-slate-800">Training Lab (WER)</h1>
</div>
<div className="bg-white p-2 rounded-xl border border-slate-200 inline-flex mb-8 shadow-sm">
<button
onClick={() => setMode('db')}
className={`flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium transition ${mode === 'db' ? 'bg-slate-900 text-white shadow' : 'text-slate-600 hover:bg-slate-50'}`}
>
<Database className="w-4 h-4" /> Audios de la BDD
</button>
<button
onClick={() => setMode('upload')}
className={`flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium transition ${mode === 'upload' ? 'bg-slate-900 text-white shadow' : 'text-slate-600 hover:bg-slate-50'}`}
>
<Upload className="w-4 h-4" /> Upload Manuel
</button>
<button
onClick={() => { setMode('suggestions'); fetchSuggestions(); }}
className={`flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium transition ${mode === 'suggestions' ? 'bg-slate-900 text-white shadow' : 'text-slate-600 hover:bg-slate-50'}`}
>
<Lightbulb className="w-4 h-4" /> Suggestions Auto-Normalisation
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{mode === 'suggestions' && (
<div className="lg:col-span-3 space-y-6">
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-amber-500" />
Auto-Normalisation (Top 20)
</h2>
<p className="text-sm text-slate-500 mt-1">Ces mots ont été fréquemment corrigés manuellement. Validez-les pour les injecter dans le dictionnaire Wolof.</p>
</div>
<div className="flex gap-3">
<button
onClick={recalculateWER}
disabled={recalculating}
className="flex items-center gap-2 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 font-medium rounded-lg transition disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${recalculating ? 'animate-spin' : ''}`} />
Recalculer WER Global
</button>
<button
onClick={applySuggestions}
disabled={submitting || selectedSuggestions.size === 0}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition disabled:opacity-50"
>
<Save className="w-4 h-4" />
Injecter ({selectedSuggestions.size}) Règles
</button>
</div>
</div>
{recalcResult && (
<div className="mb-8 p-4 bg-emerald-50 border border-emerald-100 rounded-xl flex items-start gap-4">
<CheckCircle className="w-6 h-6 text-emerald-500 shrink-0" />
<div>
<h3 className="font-bold text-emerald-800 mb-1">Benchmark Terminé ({recalcResult.processed} audios)</h3>
<div className="flex gap-6 mt-2">
<div>
<p className="text-xs text-emerald-600 uppercase">WER Brut Moy</p>
<p className="text-xl font-bold text-emerald-900">{Math.round(recalcResult.avgRawWER * 100)}%</p>
</div>
<div>
<p className="text-xs text-emerald-600 uppercase">WER Normalisé Moy</p>
<p className="text-xl font-bold text-emerald-900">{Math.round(recalcResult.avgNormalizedWER * 100)}%</p>
</div>
<div>
<p className="text-xs text-emerald-600 uppercase">Gain de Précision</p>
<p className="text-xl font-bold text-emerald-900">+{recalcResult.improvementPercent.toFixed(2)}%</p>
</div>
</div>
</div>
</div>
)}
{loading ? <p className="text-slate-500 py-10 text-center flex justify-center"><RefreshCw className="animate-spin text-indigo-500" /></p> : suggestions.length === 0 ? (
<div className="text-center py-12 px-4 bg-slate-50 rounded-xl border border-dashed border-slate-200">
<CheckCircle className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500">Aucune nouvelle suggestion détectée.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-slate-600">
<thead className="text-xs text-slate-500 uppercase bg-slate-50">
<tr>
<th className="px-6 py-3 rounded-tl-xl"><input type="checkbox" checked={selectedSuggestions.size === suggestions.length} onChange={(e) => setSelectedSuggestions(e.target.checked ? new Set(suggestions.map(s => `${s.original}->${s.replacement}`)) : new Set())} /></th>
<th className="px-6 py-3">Erreur (Whisper)</th>
<th className="px-6 py-3">Correction (Humain)</th>
<th className="px-6 py-3 rounded-tr-xl">Fréquence</th>
</tr>
</thead>
<tbody>
{suggestions.map(s => {
const key = `${s.original}->${s.replacement}`;
return (
<tr key={key} className="border-b border-slate-100 last:border-0 hover:bg-slate-50">
<td className="px-6 py-4">
<input type="checkbox" checked={selectedSuggestions.has(key)} onChange={(e) => {
const newSet = new Set(selectedSuggestions);
if (e.target.checked) newSet.add(key); else newSet.delete(key);
setSelectedSuggestions(newSet);
}} />
</td>
<td className="px-6 py-4 font-mono text-orange-600">{s.original}</td>
<td className="px-6 py-4 font-mono text-emerald-600 font-bold">{s.replacement}</td>
<td className="px-6 py-4">
<span className="bg-indigo-50 text-indigo-700 px-2.5 py-1 rounded-full text-xs font-semibold">{s.count} fois</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{/* Left Sidebar: List */}
<div className={`bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm h-[600px] flex flex-col ${mode === 'suggestions' ? 'hidden' : ''}`}>
<div className="p-4 border-b border-slate-100 bg-slate-50 flex items-center justify-between">
<h2 className="font-semibold text-slate-800 flex items-center gap-2">
<Database className="w-4 h-4 text-slate-400" />
File d'attente ({audios.length})
</h2>
<button onClick={fetchAudios} disabled={loading} className="text-slate-400 hover:text-slate-600">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="flex-1 overflow-auto p-2 space-y-2">
{loading && <p className="text-center text-slate-400 py-6 text-sm">Chargement...</p>}
{!loading && audios.length === 0 && (
<div className="text-center py-10 px-4">
<CheckCircle className="w-12 h-12 text-emerald-400 mx-auto mb-3 opacity-50" />
<p className="text-sm text-slate-500">Aucun audio en attente de révision.</p>
</div>
)}
{audios.map(audio => (
<button
key={audio.id}
onClick={() => { setSelectedAudio(audio); setManualCorrection(''); setResult(null); }}
className={`w-full text-left p-4 rounded-xl transition border text-sm ${selectedAudio?.id === audio.id ? 'bg-indigo-50 border-indigo-200 ring-1 ring-indigo-200' : 'bg-white border-transparent hover:border-slate-200 hover:bg-slate-50'}`}
>
<p className="font-medium text-slate-800 truncate mb-1">{audio.transcription || 'Sans transcription'}</p>
<p className="text-xs text-slate-400 truncate">{new Date(audio.createdAt).toLocaleString()}</p>
</button>
))}
</div>
</div>
{/* Right Area: Editor */}
<div className={`lg:col-span-2 space-y-6 ${mode === 'suggestions' ? 'hidden' : ''}`}>
{mode === 'upload' && !selectedAudio && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-10 text-center">
<Upload className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-slate-800 mb-2">Upload Manuel (Bientôt disponible)</h3>
<p className="text-sm text-slate-500 mb-6">Uploadez un fichier .wav/.mp3 pour le transcrire et l'ajouter au dataset d'entraînement local.</p>
<input type="file" className="block w-full max-w-sm mx-auto text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" />
</div>
)}
{selectedAudio && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<div className="mb-6">
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">Audio Source</h3>
<audio src={selectedAudio.audioUrl} controls className="w-full h-12 outline-none" />
</div>
<div className="mb-6">
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">Whisper Genération v1</h3>
<div className="p-4 bg-slate-50 rounded-xl border border-slate-100 text-slate-800 text-sm leading-relaxed">
{selectedAudio.transcription}
</div>
</div>
<div className="mb-6">
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3 flex items-center justify-between">
Vérité Terrain (Ground Truth)
<span className="text-xs font-normal text-indigo-500 bg-indigo-50 px-2 py-0.5 rounded-full">Wolof Standardisé</span>
</h3>
<textarea
value={manualCorrection}
onChange={e => setManualCorrection(e.target.value)}
placeholder="Écrivez la transcription manuelle parfaite ici..."
className="w-full min-h-[120px] p-4 bg-white border border-slate-200 rounded-xl text-sm leading-relaxed outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y"
/>
</div>
<div className="flex items-center justify-end">
<button
onClick={handleSubmit}
disabled={submitting || !manualCorrection.trim()}
className="flex items-center gap-2 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{submitting ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Enregistrer & Recalculer WER
</button>
</div>
</div>
)}
{result && (
<div className="bg-gradient-to-br from-indigo-900 to-slate-900 rounded-2xl p-6 text-white shadow-lg overflow-hidden relative">
<div className="absolute top-0 right-0 p-8 opacity-5">
<Activity className="w-48 h-48" />
</div>
<h3 className="text-lg font-bold mb-6 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-emerald-400" />
Entraînement enregistré !
</h3>
<div className="grid grid-cols-2 gap-4 mb-6 relative z-10">
<div className="bg-white/10 rounded-xl p-4 border border-white/5">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">WER Brut (Whisper)</p>
<p className="text-3xl font-bold">{Math.round(result.rawWER * 100)}%</p>
</div>
<div className="bg-white/10 rounded-xl p-4 border border-white/5">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">WER Normalisé (Dico)</p>
<p className="text-3xl font-bold text-emerald-400">{Math.round(result.normalizedWER * 100)}%</p>
</div>
</div>
{result.missingWords && result.missingWords.length > 0 && (
<div className="bg-orange-500/10 border border-orange-500/20 rounded-xl p-4 relative z-10">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-orange-300 mb-2">Mots absents du dictionnaire Wolof :</p>
<div className="flex flex-wrap gap-2">
{result.missingWords.map(w => (
<span key={w} className="px-2 py-1 bg-orange-500/20 text-orange-200 text-xs rounded-md font-mono">{w}</span>
))}
</div>
<p className="text-xs text-orange-400/70 mt-3">Suggérez d'ajouter ces mots dans `normalizeWolof.ts` pour améliorer le taux de reconnaissance global de la plateforme.</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}