fintechdarkpatterns / src /app /components /ModelPerformance.tsx
yujisium's picture
Fintech Dark Patterns NLP Detector - full project upload
5752a28 verified
Raw
History Blame Contribute Delete
14.7 kB
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>
);
}