neev2211's picture
CRITICAL FIX: Use correct Wav2Vec2 model (lv-60-espeak-cv-ft) matching training
00ebb72
Raw History Blame Contribute Delete
20.8 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin — Model Training</title>
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #fafaf9;
--surface: #fff;
--surface2: #f5f5f4;
--surface3: #e7e5e4;
--text: #1c1917;
--text2: #57534e;
--text3: #a8a29e;
--accent: #2563eb;
--accent2: #3b82f6;
--accent-soft: #dbeafe;
--green: #16a34a;
--green-soft: #dcfce7;
--red: #dc2626;
--red-soft: #fee2e2;
--amber: #d97706;
--amber-soft: #fef3c7;
--border: #e7e5e4;
--radius: 10px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
}
body {
font-family: "DM Sans", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 32px 24px;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
gap: 16px;
flex-wrap: wrap;
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-green {
background: var(--green-soft);
color: var(--green);
}
.badge-amber {
background: var(--amber-soft);
color: var(--amber);
}
.badge-red {
background: var(--red-soft);
color: var(--red);
}
.btn {
padding: 8px 16px;
border-radius: 8px;
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--border);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s;
}
.btn-primary {
background: var(--text);
color: #fff;
border-color: var(--text);
}
.btn-primary:hover {
background: #292524;
}
.btn-ghost {
background: transparent;
color: var(--text2);
}
.btn-ghost:hover {
background: var(--surface2);
}
/* Card */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.card-title {
font-size: 1rem;
font-weight: 700;
margin-bottom: 4px;
}
.card-desc {
color: var(--text2);
font-size: 0.85rem;
margin-bottom: 16px;
}
/* Upload Zone */
.upload-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: var(--surface2);
}
.upload-zone:hover,
.upload-zone.drag-over {
border-color: var(--accent);
background: var(--accent-soft);
}
.upload-zone .icon {
font-size: 2rem;
margin-bottom: 8px;
}
.upload-zone .label {
font-weight: 600;
font-size: 0.95rem;
}
.upload-zone .hint {
color: var(--text3);
font-size: 0.8rem;
margin-top: 4px;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--surface2);
border-radius: 8px;
margin-top: 12px;
}
.file-info .name {
font-weight: 600;
font-size: 0.9rem;
flex: 1;
}
.file-info .remove {
color: var(--red);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
/* Progress */
.progress-section {
display: none;
}
.progress-section.visible {
display: block;
}
.progress-bar-wrap {
height: 8px;
background: var(--surface3);
border-radius: 4px;
overflow: hidden;
margin: 12px 0;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: 4px;
transition: width 0.3s;
width: 0%;
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text2);
}
.phase-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 8px;
}
.phase-downloading {
background: #dbeafe;
color: #2563eb;
}
.phase-embedding {
background: #ede9fe;
color: #7c3aed;
}
.phase-training {
background: var(--amber-soft);
color: var(--amber);
}
.phase-complete {
background: var(--green-soft);
color: var(--green);
}
.phase-error {
background: var(--red-soft);
color: var(--red);
}
/* Log */
.log-panel {
background: #1c1917;
color: #d6d3d1;
border-radius: 8px;
padding: 16px;
max-height: 400px;
overflow-y: auto;
font-family: "JetBrains Mono", monospace;
font-size: 0.78rem;
line-height: 1.7;
margin-top: 12px;
}
.log-panel .log-line {
white-space: pre-wrap;
word-break: break-all;
}
.log-panel .log-time {
color: #a8a29e;
margin-right: 8px;
}
.log-panel .log-error {
color: #f87171;
}
.log-panel::-webkit-scrollbar {
width: 6px;
}
.log-panel::-webkit-scrollbar-track {
background: transparent;
}
.log-panel::-webkit-scrollbar-thumb {
background: #57534e;
border-radius: 3px;
}
/* Metrics */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-top: 16px;
}
.metric-card {
background: var(--surface2);
border-radius: 8px;
padding: 14px;
text-align: center;
}
.metric-card .val {
font-size: 1.3rem;
font-weight: 700;
color: var(--accent);
}
.metric-card .lbl {
font-size: 0.75rem;
color: var(--text2);
margin-top: 2px;
}
/* Confusion Matrix */
.cm-table {
margin-top: 12px;
border-collapse: collapse;
}
.cm-table th,
.cm-table td {
padding: 8px 16px;
text-align: center;
border: 1px solid var(--border);
font-size: 0.85rem;
}
.cm-table th {
background: var(--surface2);
font-weight: 600;
}
.cm-table td {
font-family: "JetBrains Mono", monospace;
}
/* Format info */
.format-box {
background: var(--surface2);
border-radius: 8px;
padding: 12px 16px;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
color: var(--text2);
margin-bottom: 16px;
border-left: 3px solid var(--accent);
}
.info-box {
background: #dbeafe;
border: 1px solid #93c5fd;
border-radius: 8px;
padding: 12px 16px;
font-size: 0.85rem;
color: #1e40af;
margin-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div>
<h1>Admin Panel</h1>
<span style="color: var(--text2); font-size: 0.85rem"
>Model training & management</span
>
</div>
<div class="header-actions">
<a href="/" class="btn btn-ghost">Back to App</a>
<a
href="/admin/logout"
class="btn btn-ghost"
style="color: var(--red)"
>Logout</a
>
</div>
</div>
<!-- Upload Card -->
<div class="card" id="upload-card">
<div class="card-title">Train New Model</div>
<div class="card-desc">
Upload a CSV to retrain the nativity classifier. This will download
audio, extract embeddings, and train from scratch.
</div>
<div class="format-box">
Required CSV columns:<br />
<strong>dp_id</strong>, <strong>audio_url</strong>,
<strong>nativity_status</strong>, <strong>language</strong>
</div>
<div class="info-box">
Embeddings are stored on the server only and are not exposed in the
public repository. Training typically takes 15-45 minutes depending on
dataset size.
</div>
<div class="info-box" style="background:#fef3c7;border-color:#fbbf24;color:#92400e;">
&#9888; On Hugging Face Spaces, storage is ephemeral. Trained models will be lost
when the Space restarts. Use the <strong>Download Model</strong> button after training
to save your model locally, then re-upload the files to the repository.
</div>
<div
class="upload-zone"
id="drop-zone"
onclick="document.getElementById('csv-input').click()"
>
<div class="icon">&#128203;</div>
<div class="label">Drop CSV here or click to upload</div>
<div class="hint">dp_id, audio_url, nativity_status, language</div>
</div>
<input type="file" id="csv-input" accept=".csv" style="display: none" />
<div id="file-info-box" style="display: none">
<div class="file-info">
<span class="name" id="file-name"></span>
<span class="remove" onclick="clearFile()">Remove</span>
</div>
</div>
<div style="margin-top: 16px; text-align: right">
<button
class="btn btn-primary"
id="train-btn"
onclick="startTraining()"
disabled
>
Start Training
</button>
</div>
</div>
<!-- Progress Card -->
<div class="card progress-section" id="progress-card">
<div class="card-title">Training Progress</div>
<div style="margin-top: 12px">
<span class="phase-badge" id="phase-badge">Starting...</span>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar" id="progress-bar"></div>
</div>
<div class="progress-label">
<span id="progress-text">0 / 0</span>
<span id="progress-phase"></span>
</div>
<div class="log-panel" id="log-panel"></div>
</div>
<!-- Results Card -->
<div class="card progress-section" id="results-card">
<div class="card-title">Training Results</div>
<div id="results-content"></div>
</div>
</div>
<script>
let selectedFile = null;
// Drag & drop
const dropZone = document.getElementById("drop-zone");
["dragenter", "dragover"].forEach((e) =>
dropZone.addEventListener(e, (ev) => {
ev.preventDefault();
dropZone.classList.add("drag-over");
}),
);
["dragleave", "drop"].forEach((e) =>
dropZone.addEventListener(e, (ev) => {
ev.preventDefault();
dropZone.classList.remove("drag-over");
}),
);
dropZone.addEventListener("drop", (ev) => {
if (ev.dataTransfer.files.length) setFile(ev.dataTransfer.files[0]);
});
document.getElementById("csv-input").addEventListener("change", (ev) => {
if (ev.target.files.length) setFile(ev.target.files[0]);
});
function setFile(file) {
if (!file.name.endsWith(".csv")) {
alert("Please upload a CSV file.");
return;
}
selectedFile = file;
document.getElementById("file-name").textContent =
`${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
document.getElementById("file-info-box").style.display = "block";
document.getElementById("train-btn").disabled = false;
dropZone.style.display = "none";
}
function clearFile() {
selectedFile = null;
document.getElementById("file-info-box").style.display = "none";
document.getElementById("train-btn").disabled = true;
dropZone.style.display = "";
document.getElementById("csv-input").value = "";
}
async function startTraining() {
if (!selectedFile) return;
const btn = document.getElementById("train-btn");
btn.disabled = true;
btn.textContent = "Starting...";
const fd = new FormData();
fd.append("file", selectedFile);
try {
const resp = await fetch("/admin/api/train", {
method: "POST",
body: fd,
});
const data = await resp.json();
if (!resp.ok) {
alert(data.error || "Failed to start training");
btn.disabled = false;
btn.textContent = "Start Training";
return;
}
// Show progress
document.getElementById("upload-card").style.display = "none";
document.getElementById("progress-card").classList.add("visible");
document.getElementById("results-card").classList.add("visible");
streamProgress();
} catch (e) {
alert("Error: " + e.message);
btn.disabled = false;
btn.textContent = "Start Training";
}
}
function streamProgress() {
const evtSource = new EventSource("/admin/api/stream_train");
const logPanel = document.getElementById("log-panel");
evtSource.onmessage = function (event) {
const d = JSON.parse(event.data);
// Phase badge
const phaseBadge = document.getElementById("phase-badge");
const phaseMap = {
starting: ["Starting...", ""],
loading_csv: ["Loading CSV", ""],
downloading: ["Downloading Audio", "phase-downloading"],
embedding: ["Extracting Embeddings", "phase-embedding"],
training: ["Training Model", "phase-training"],
complete: ["Complete", "phase-complete"],
};
const [phaseText, phaseClass] = phaseMap[d.phase] || [d.phase, ""];
phaseBadge.textContent = phaseText;
phaseBadge.className =
"phase-badge " + (d.error ? "phase-error" : phaseClass);
// Progress bar
const pct =
d.total > 0 ? Math.round((d.progress / d.total) * 100) : 0;
document.getElementById("progress-bar").style.width = pct + "%";
document.getElementById("progress-text").textContent =
`${d.progress} / ${d.total}`;
document.getElementById("progress-phase").textContent = phaseText;
// New logs
if (d.logs && d.logs.length) {
d.logs.forEach((log) => {
const div = document.createElement("div");
div.className =
"log-line" +
(log.msg.includes("ERROR") || log.msg.includes("FAILED")
? " log-error"
: "");
div.innerHTML = `<span class="log-time">${log.time}</span>${escapeHtml(log.msg)}`;
logPanel.appendChild(div);
});
logPanel.scrollTop = logPanel.scrollHeight;
}
// Error
if (d.error) {
phaseBadge.textContent = "Error";
document.getElementById("results-content").innerHTML =
`<div style="color:var(--red);font-weight:600;margin-top:12px;">Training failed: ${escapeHtml(d.error)}</div>`;
}
// Done
if (d.done) {
evtSource.close();
if (d.metrics) renderMetrics(d.metrics);
}
};
evtSource.onerror = function () {
evtSource.close();
// Poll status once more
fetch("/admin/api/status")
.then((r) => r.json())
.then((d) => {
if (d.metrics) renderMetrics(d.metrics);
});
};
}
function renderMetrics(m) {
const rc = document.getElementById("results-content");
let html = `
<div class="metrics-grid">
<div class="metric-card"><div class="val">${(m.accuracy * 100).toFixed(1)}%</div><div class="lbl">Accuracy</div></div>
<div class="metric-card"><div class="val">${(m.f1_macro * 100).toFixed(1)}%</div><div class="lbl">F1 (macro)</div></div>
<div class="metric-card"><div class="val">${(m.precision * 100).toFixed(1)}%</div><div class="lbl">Precision</div></div>
<div class="metric-card"><div class="val">${(m.recall * 100).toFixed(1)}%</div><div class="lbl">Recall</div></div>
<div class="metric-card"><div class="val">${m.threshold}</div><div class="lbl">Threshold</div></div>
<div class="metric-card"><div class="val">${m.total_chunks}</div><div class="lbl">Total Chunks</div></div>
<div class="metric-card"><div class="val">${m.total_speakers}</div><div class="lbl">Speakers</div></div>
</div>`;
if (m.confusion_matrix && m.confusion_matrix.length === 2) {
const cm = m.confusion_matrix;
html += `
<div style="margin-top:20px;">
<div style="font-weight:600;font-size:0.9rem;margin-bottom:8px;">Confusion Matrix</div>
<table class="cm-table">
<tr><th></th><th>Pred Native</th><th>Pred Non-Native</th></tr>
<tr><th>True Native</th><td>${cm[0][0]}</td><td>${cm[0][1]}</td></tr>
<tr><th>True Non-Native</th><td>${cm[1][0]}</td><td>${cm[1][1]}</td></tr>
</table>
</div>`;
}
html += `
<div style="margin-top:20px;text-align:right;">
<a href="/admin/api/download_model" class="btn btn-primary" style="margin-right:8px;">&#11015; Download Model (.zip)</a>
<button class="btn btn-primary" onclick="location.reload()">Train Another Model</button>
<a href="/" class="btn btn-ghost" style="margin-left:8px;">Go to Classifier</a>
</div>`;
rc.innerHTML = html;
}
function escapeHtml(str) {
const d = document.createElement("div");
d.textContent = str;
return d.innerHTML;
}
// Check if training is already in progress
fetch("/admin/api/status")
.then((r) => r.json())
.then((d) => {
if (d.active || (d.done && d.logs.length > 0)) {
document.getElementById("upload-card").style.display = "none";
document.getElementById("progress-card").classList.add("visible");
document.getElementById("results-card").classList.add("visible");
// Replay existing logs
const logPanel = document.getElementById("log-panel");
d.logs.forEach((log) => {
const div = document.createElement("div");
div.className = "log-line";
div.innerHTML = `<span class="log-time">${log.time}</span>${escapeHtml(log.msg)}`;
logPanel.appendChild(div);
});
logPanel.scrollTop = logPanel.scrollHeight;
if (d.active) streamProgress();
if (d.metrics) renderMetrics(d.metrics);
if (d.error) {
document.getElementById("phase-badge").textContent = "Error";
document.getElementById("phase-badge").className =
"phase-badge phase-error";
document.getElementById("results-content").innerHTML =
`<div style="color:var(--red);font-weight:600;">Training failed: ${escapeHtml(d.error)}</div>`;
}
}
});
</script>
</body>
</html>