| | 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; |
| | 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 |
| | }); |
| | |
| | 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> |
| | ); |
| | } |
| |
|