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; classDistribution: Record; rareClasses: Record; 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(null); const [metricsError, setMetricsError] = useState(null); const [text, setText] = useState(''); const [testing, setTesting] = useState(false); const [result, setResult] = useState(null); const [testError, setTestError] = useState(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 (
{/* ── Live Text Lab ─────────────────────────────────────────── */}

Live Text Lab

Paste any fintech copywriting and see what the model thinks — and which phrases drove the decision.

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" />
{EXAMPLE_TEXTS.map((t) => ( ))}
{testError && (
{testError}
)} {result && (
{result.isDarkPattern ? ( ) : ( )} {result.prediction} {result.confidence}% calibrated confidence ({result.confidenceBand})
{result.explanation.length > 0 && (
Phrases that triggered this verdict
{result.explanation.map((e) => ( {e.phrase} ))}
)}
{result.topClasses.map((c) => (
{c.label}
{c.probability}%
))}
{result.cfpbViolation && (
Compliance: {result.cfpbViolation}
)}
)}
{/* ── Evaluation metrics ────────────────────────────────────── */}

Model Performance

Evaluated on a grouped held-out fold. Text from the same source page never appears in both training and testing.

{metricsError && (
Failed to load metrics: {metricsError}
)} {!metrics && !metricsError && (
)} {metrics && (
{metrics.modelName}
{metrics.splitMethod} · {metrics.testGroupCount} test pages · {metrics.groupOverlap} page overlap · {metrics.calibrationMethod}
{[ { 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) => (
{s.label}
{s.value}
))}
{/* Per-class table */}

F1 score per pattern class

{Object.entries(metrics.perClass) .sort((a, b) => b[1].f1 - a[1].f1) .map(([cls, s]) => (
{cls}
{s.f1.toFixed(2)}
n={s.support}
))}
{/* Low-support warning — honest about dataset limitations */} {Object.keys(metrics.rareClasses).length > 0 && (

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.

)} {/* Confusion matrix */}

Confusion matrix (rows = truth, columns = prediction)

))} {metrics.confusionMatrix.matrix.map((row, i) => { const rowMax = Math.max(...row, 1); return ( {row.map((v, j) => ( ))} ); })}
{metrics.confusionMatrix.labels.map((l) => ( {l.split(' ')[0]}
{metrics.confusionMatrix.labels[i]} 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}
)}
); }