Spaces:
Runtime error
Runtime error
| import { useState, useEffect } from 'react'; | |
| import { Gauge, FlaskConical, Loader2, AlertTriangle, Sparkles, ShieldAlert, ShieldCheck } from 'lucide-react'; | |
| import { Card } from './ui/card'; | |
| import { Button } from './ui/button'; | |
| import { API_BASE_URL } from '../config'; | |
| interface ClassStats { | |
| precision: number; | |
| recall: number; | |
| f1: number; | |
| support: number; | |
| } | |
| interface Metrics { | |
| modelName: string; | |
| datasetSize: number; | |
| numClasses: number; | |
| testSize: number; | |
| testFraction: number; | |
| trainGroupCount: number; | |
| testGroupCount: number; | |
| groupOverlap: number; | |
| splitMethod: string; | |
| accuracy: number; | |
| macroF1: number; | |
| weightedF1: number; | |
| logLoss: number; | |
| calibrated: boolean; | |
| calibrationMethod: string; | |
| perClass: Record<string, ClassStats>; | |
| classDistribution: Record<string, number>; | |
| rareClasses: Record<string, number>; | |
| minimumRecommendedClassSize: number; | |
| confusionMatrix: { labels: string[]; matrix: number[][] }; | |
| } | |
| interface TextResult { | |
| text: string; | |
| prediction: string; | |
| confidence: number; | |
| isDarkPattern: boolean; | |
| topClasses: { label: string; probability: number }[]; | |
| explanation: { phrase: string; weight: number }[]; | |
| calibrated: boolean; | |
| confidenceBand: string; | |
| cfpbViolation?: string; | |
| recommendation?: string; | |
| } | |
| const EXAMPLE_TEXTS = [ | |
| 'Hurry! Only 3 left in stock — sale ends in 10 minutes!', | |
| '2,847 customers bought this in the last 24 hours', | |
| 'No thanks, I hate saving money', | |
| 'Your monthly statement is available in the documents section.', | |
| ]; | |
| export function ModelPerformance() { | |
| const [metrics, setMetrics] = useState<Metrics | null>(null); | |
| const [metricsError, setMetricsError] = useState<string | null>(null); | |
| const [text, setText] = useState(''); | |
| const [testing, setTesting] = useState(false); | |
| const [result, setResult] = useState<TextResult | null>(null); | |
| const [testError, setTestError] = useState<string | null>(null); | |
| useEffect(() => { | |
| fetch(`${API_BASE_URL}/api/metrics`) | |
| .then((r) => { | |
| if (!r.ok) throw new Error(`Server returned ${r.status}`); | |
| return r.json(); | |
| }) | |
| .then(setMetrics) | |
| .catch((e) => setMetricsError(e.message)); | |
| }, []); | |
| const runTest = async (input?: string) => { | |
| const value = (input ?? text).trim(); | |
| if (value.length < 3) return; | |
| if (input) setText(input); | |
| setTesting(true); | |
| setTestError(null); | |
| setResult(null); | |
| try { | |
| const r = await fetch(`${API_BASE_URL}/api/analyze-text`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text: value }), | |
| }); | |
| const data = await r.json(); | |
| if (!r.ok || data.error) throw new Error(data.error || `Server returned ${r.status}`); | |
| setResult(data); | |
| } catch (e: any) { | |
| setTestError(e.message); | |
| } finally { | |
| setTesting(false); | |
| } | |
| }; | |
| const f1Color = (f1: number) => | |
| f1 >= 0.9 ? 'bg-emerald-500' : f1 >= 0.7 ? 'bg-amber-500' : 'bg-rose-500'; | |
| return ( | |
| <div className="space-y-8"> | |
| {/* ── Live Text Lab ─────────────────────────────────────────── */} | |
| <Card className="p-8 bg-card/40 backdrop-blur-md border border-border shadow-xl rounded-2xl"> | |
| <h2 className="text-xl font-bold text-foreground mb-1 flex items-center gap-2"> | |
| <FlaskConical className="w-5 h-5 text-indigo-500 dark:text-indigo-400" /> | |
| Live Text Lab | |
| </h2> | |
| <p className="text-xs text-muted-foreground mb-6"> | |
| Paste any fintech copywriting and see what the model thinks — and which phrases drove the decision. | |
| </p> | |
| <div className="flex flex-col md:flex-row gap-3"> | |
| <input | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && runTest()} | |
| placeholder='e.g. "Hurry! Only 2 left in stock!"' | |
| className="flex-1 px-4 py-3 rounded-xl bg-muted/20 border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-indigo-500/60" | |
| /> | |
| <Button | |
| onClick={() => runTest()} | |
| disabled={testing || text.trim().length < 3} | |
| className="h-12 px-8 rounded-xl text-xs font-bold uppercase tracking-wider bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white" | |
| > | |
| {testing ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Classify'} | |
| </Button> | |
| </div> | |
| <div className="flex flex-wrap gap-2 mt-3"> | |
| {EXAMPLE_TEXTS.map((t) => ( | |
| <button | |
| key={t} | |
| onClick={() => runTest(t)} | |
| className="text-[11px] px-3 py-1.5 rounded-full border border-border bg-muted/10 text-muted-foreground hover:text-foreground hover:border-indigo-500/40 transition-colors" | |
| > | |
| {t} | |
| </button> | |
| ))} | |
| </div> | |
| {testError && ( | |
| <div className="mt-4 p-3 rounded-xl text-rose-500 bg-rose-500/10 border border-rose-500/25 text-xs font-semibold"> | |
| {testError} | |
| </div> | |
| )} | |
| {result && ( | |
| <div className="mt-6 p-5 rounded-xl border border-border bg-muted/10 space-y-4"> | |
| <div className="flex items-center gap-3 flex-wrap"> | |
| {result.isDarkPattern ? ( | |
| <ShieldAlert className="w-6 h-6 text-rose-500" /> | |
| ) : ( | |
| <ShieldCheck className="w-6 h-6 text-emerald-500" /> | |
| )} | |
| <span | |
| className={`text-sm font-extrabold uppercase tracking-wide ${ | |
| result.isDarkPattern ? 'text-rose-500' : 'text-emerald-500' | |
| }`} | |
| > | |
| {result.prediction} | |
| </span> | |
| <span className="text-xs font-mono text-muted-foreground"> | |
| {result.confidence}% calibrated confidence ({result.confidenceBand}) | |
| </span> | |
| </div> | |
| {result.explanation.length > 0 && ( | |
| <div> | |
| <div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2 flex items-center gap-1.5"> | |
| <Sparkles className="w-3 h-3" /> Phrases that triggered this verdict | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| {result.explanation.map((e) => ( | |
| <span | |
| key={e.phrase} | |
| className="text-xs font-mono px-2.5 py-1 rounded-md bg-indigo-500/10 text-indigo-500 dark:text-indigo-300 border border-indigo-500/25" | |
| title={`weight ${e.weight}`} | |
| > | |
| {e.phrase} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-3 gap-2"> | |
| {result.topClasses.map((c) => ( | |
| <div key={c.label} className="p-2.5 rounded-lg bg-muted/20 border border-border"> | |
| <div className="text-[10px] text-muted-foreground truncate">{c.label}</div> | |
| <div className="text-sm font-bold text-foreground">{c.probability}%</div> | |
| </div> | |
| ))} | |
| </div> | |
| {result.cfpbViolation && ( | |
| <div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/25 text-xs text-amber-600 dark:text-amber-400"> | |
| <strong>Compliance:</strong> {result.cfpbViolation} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </Card> | |
| {/* ── Evaluation metrics ────────────────────────────────────── */} | |
| <Card className="p-8 bg-card/40 backdrop-blur-md border border-border shadow-xl rounded-2xl"> | |
| <h2 className="text-xl font-bold text-foreground mb-1 flex items-center gap-2"> | |
| <Gauge className="w-5 h-5 text-indigo-500 dark:text-indigo-400" /> | |
| Model Performance | |
| </h2> | |
| <p className="text-xs text-muted-foreground mb-6"> | |
| Evaluated on a grouped held-out fold. Text from the same source page never appears in both training and testing. | |
| </p> | |
| {metricsError && ( | |
| <div className="p-4 rounded-xl text-rose-500 bg-rose-500/10 border border-rose-500/25 text-xs font-semibold"> | |
| Failed to load metrics: {metricsError} | |
| </div> | |
| )} | |
| {!metrics && !metricsError && ( | |
| <div className="flex items-center justify-center py-16"> | |
| <Loader2 className="w-6 h-6 animate-spin text-indigo-500" /> | |
| </div> | |
| )} | |
| {metrics && ( | |
| <div className="space-y-8"> | |
| <div className="text-xs font-mono text-muted-foreground">{metrics.modelName}</div> | |
| <div className="text-[11px] text-muted-foreground"> | |
| {metrics.splitMethod} · {metrics.testGroupCount} test pages · {metrics.groupOverlap} page overlap · {metrics.calibrationMethod} | |
| </div> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> | |
| {[ | |
| { label: 'Accuracy', value: `${(metrics.accuracy * 100).toFixed(1)}%` }, | |
| { label: 'Macro F1', value: metrics.macroF1.toFixed(3) }, | |
| { label: 'Weighted F1', value: metrics.weightedF1.toFixed(3) }, | |
| { label: 'Training samples', value: metrics.datasetSize.toLocaleString() }, | |
| ].map((s) => ( | |
| <div key={s.label} className="p-4 rounded-xl bg-muted/10 border border-border"> | |
| <div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> | |
| {s.label} | |
| </div> | |
| <div className="text-2xl font-extrabold text-foreground mt-1">{s.value}</div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Per-class table */} | |
| <div> | |
| <h3 className="text-sm font-bold text-foreground mb-3">F1 score per pattern class</h3> | |
| <div className="space-y-2"> | |
| {Object.entries(metrics.perClass) | |
| .sort((a, b) => b[1].f1 - a[1].f1) | |
| .map(([cls, s]) => ( | |
| <div key={cls} className="flex items-center gap-3"> | |
| <div className="w-36 text-xs font-semibold text-foreground truncate">{cls}</div> | |
| <div className="flex-1 h-3 rounded-full bg-muted/30 overflow-hidden"> | |
| <div | |
| className={`h-full rounded-full ${f1Color(s.f1)}`} | |
| style={{ width: `${Math.max(s.f1 * 100, 2)}%` }} | |
| /> | |
| </div> | |
| <div className="w-12 text-xs font-mono text-foreground text-right"> | |
| {s.f1.toFixed(2)} | |
| </div> | |
| <div className="w-20 text-[10px] font-mono text-muted-foreground text-right"> | |
| n={s.support} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Low-support warning — honest about dataset limitations */} | |
| {Object.keys(metrics.rareClasses).length > 0 && ( | |
| <div className="flex items-start gap-2.5 p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/25"> | |
| <AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" /> | |
| <p className="text-xs text-amber-600 dark:text-amber-400 leading-relaxed"> | |
| Classes below {metrics.minimumRecommendedClassSize} total samples remain unreliable:{' '} | |
| {Object.entries(metrics.rareClasses) | |
| .map(([label, count]) => `${label} (${count})`) | |
| .join(', ')}. No synthetic records were added to inflate these scores. | |
| </p> | |
| </div> | |
| )} | |
| {/* Confusion matrix */} | |
| <div> | |
| <h3 className="text-sm font-bold text-foreground mb-3">Confusion matrix (rows = truth, columns = prediction)</h3> | |
| <div className="overflow-x-auto"> | |
| <table className="text-[10px] font-mono border-collapse"> | |
| <thead> | |
| <tr> | |
| <th className="p-1.5" /> | |
| {metrics.confusionMatrix.labels.map((l) => ( | |
| <th key={l} className="p-1.5 text-muted-foreground font-semibold max-w-16 truncate" title={l}> | |
| {l.split(' ')[0]} | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {metrics.confusionMatrix.matrix.map((row, i) => { | |
| const rowMax = Math.max(...row, 1); | |
| return ( | |
| <tr key={metrics.confusionMatrix.labels[i]}> | |
| <td className="p-1.5 text-muted-foreground font-semibold text-right pr-3 whitespace-nowrap"> | |
| {metrics.confusionMatrix.labels[i]} | |
| </td> | |
| {row.map((v, j) => ( | |
| <td | |
| key={j} | |
| className={`p-1.5 text-center min-w-12 rounded ${ | |
| v === 0 | |
| ? 'text-muted-foreground/40' | |
| : i === j | |
| ? 'text-emerald-500 font-bold' | |
| : 'text-rose-500 font-bold' | |
| }`} | |
| style={{ | |
| backgroundColor: | |
| v > 0 | |
| ? i === j | |
| ? `rgba(16,185,129,${0.08 + 0.3 * (v / rowMax)})` | |
| : `rgba(244,63,94,${0.08 + 0.3 * (v / rowMax)})` | |
| : undefined, | |
| }} | |
| > | |
| {v} | |
| </td> | |
| ))} | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </Card> | |
| </div> | |
| ); | |
| } | |