/* ============================================================ page-ml.jsx — Neural decision layer: shadow review + metrics Visual-first admin surface: every number gets a chart and a plain-language caption, plus a built-in "How it works" guide. ============================================================ */ const ML_HEADS = ['flag_checkin', 'uphold_strike', 'auto_approve_pto', 'nudge_effective', 'noshow_risk']; const ML_HEAD_LABELS = { flag_checkin: 'Flag check-in', uphold_strike: 'Uphold strike', auto_approve_pto: 'Auto-approve PTO', nudge_effective: 'Nudge timing', noshow_risk: 'No-show risk', }; const ML_HEAD_PLAIN = { flag_checkin: 'Would an admin want this check-in flagged?', uphold_strike: 'Would an admin let this strike stand?', auto_approve_pto: 'Would an admin approve this time-off request?', nudge_effective: 'Will a reminder right now actually work?', noshow_risk: 'Will this person miss check-in entirely today?', }; // Fixed per-head colors (identity follows the entity, validated for dark surface) const ML_COLORS = { flag_checkin: '#3987e5', auto_approve_pto: '#199e70', nudge_effective: '#c98500', noshow_risk: '#9085e9', uphold_strike: '#e66767', }; const ML_SOURCE_LABELS = { auto_outcome: 'Self-labeled (reality)', admin_override: 'Admin actions', admin_feedback: 'Review thumbs', admin_confirm: 'Quiet confirmation', }; const ML_SOURCE_COLORS = ['#3987e5', '#199e70', '#c98500', '#9085e9']; function mlSurface() { try { const v = getComputedStyle(document.body).getPropertyValue('--card').trim(); return v || '#101014'; } catch (e) { return '#101014'; } } function mlInk() { // Chart.js defaults its legend/tick ink to dark gray — unreadable on the // dark theme. Use the page's own text color so both themes stay legible. try { const v = getComputedStyle(document.body).color; return v || '#e8e8e8'; } catch (e) { return '#e8e8e8'; } } /* ---------- tiles ---------- */ function MLStatTile({ label, value, sub, color }) { return (
{label}
{value}
{sub ?
{sub}
: null}
); } /* ---------- doughnut (Chart.js) ---------- */ function MLDoughnut({ title, caption, labels, values, colors, centerLabel }) { const ref = React.useRef(null); React.useEffect(() => { if (!window.Chart || !ref.current) return; const ctx = ref.current.getContext('2d'); if (ref.current._chart) ref.current._chart.destroy(); const total = values.reduce((a, b) => a + b, 0); ref.current._chart = new window.Chart(ctx, { type: 'doughnut', data: { labels, datasets: [{ data: values, backgroundColor: colors, borderColor: mlSurface(), borderWidth: 2, // 2px surface gap between segments hoverOffset: 6, }], }, options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, boxHeight: 10, font: { size: 10 }, color: mlInk(), generateLabels(chart) { const d = chart.data; return d.labels.map((l, i) => { const v = d.datasets[0].data[i]; const pct = total ? Math.round(v / total * 100) : 0; return { text: `${l} — ${pct}%`, fillStyle: d.datasets[0].backgroundColor[i], fontColor: mlInk(), strokeStyle: 'transparent', index: i, }; }); }, }, }, tooltip: { callbacks: { label: (c) => ` ${c.label}: ${c.raw} (${total ? Math.round(c.raw / total * 100) : 0}%)`, }, }, }, }, }); return () => { if (ref.current?._chart) ref.current._chart.destroy(); }; }, [JSON.stringify(labels), JSON.stringify(values)]); const total = values.reduce((a, b) => a + b, 0); return (
{title}
{caption}
{total === 0 ?
No data yet — this fills up as the bot works.
: (
{centerLabel ? (
{total}
{centerLabel}
) : null}
)}
); } /* ---------- per-head quality bars (plain HTML — thin marks, direct labels) ---------- */ function MLQualityBars({ champion }) { const m = (champion && champion.metrics) || {}; const rows = ML_HEADS.map(h => { const hm = m[h] || {}; return { head: h, pr: hm.pr_auc, ece: hm.ece, skipped: hm.skipped || hm.pr_auc === undefined }; }); const grade = (pr) => pr >= 0.9 ? ['Excellent', '#0ca30c'] : pr >= 0.75 ? ['Good', '#0ca30c'] : pr >= 0.6 ? ['Fair', '#fab219'] : ['Weak', '#ec835a']; return (
Model quality per decision type
Score = how well the model separates right from wrong calls (100% is perfect, tested on data it never saw).
{rows.map(r => (
{ML_HEAD_LABELS[r.head]} {r.skipped ? learning — needs more labels : {grade(r.pr)[0]} · {(r.pr * 100).toFixed(0)}%}
))}
Heads without a score simply haven't collected enough real examples yet — they fill in automatically as admins approve, decline, and forgive.
); } /* ---------- calibration ---------- */ function MLCalibrationChart({ head, metrics }) { const ref = React.useRef(null); const calib = ((metrics?.heads || {})[head] || {}).calibration || []; React.useEffect(() => { if (!window.Chart || !ref.current) return; const ctx = ref.current.getContext('2d'); if (ref.current._chart) ref.current._chart.destroy(); ref.current._chart = new window.Chart(ctx, { type: 'line', data: { labels: calib.map(c => c.bin), datasets: [ { label: 'What actually happened', data: calib.map(c => c.observed), borderColor: ML_COLORS[head] || '#9085e9', backgroundColor: 'transparent', spanGaps: true, tension: 0.2, pointRadius: 4, borderWidth: 2, }, { label: 'Perfectly honest model', data: calib.map(c => parseFloat(c.bin) + 0.05), borderColor: 'rgba(148,163,184,.55)', borderDash: [6, 4], pointRadius: 0, borderWidth: 2, }, ], }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { min: 0, max: 1, ticks: { color: mlInk() }, grid: { color: 'rgba(148,163,184,.12)' }, title: { display: true, text: 'How often it came true', font: { size: 10 }, color: mlInk() } }, x: { ticks: { color: mlInk() }, grid: { color: 'rgba(148,163,184,.12)' }, title: { display: true, text: 'What the model predicted', font: { size: 10 }, color: mlInk() } }, }, plugins: { legend: { labels: { boxWidth: 12, font: { size: 10 }, color: mlInk() } } }, }, }); return () => { if (ref.current?._chart) ref.current._chart.destroy(); }; }, [head, JSON.stringify(calib)]); const hasData = calib.some(c => c.n > 0); if (!hasData) return
Not enough labeled predictions for this decision type yet — the line appears as outcomes accumulate.
; return
; } /* ---------- the guide ---------- */ function MLGuideModal({ onClose }) { return (
e.stopPropagation()}>
🧠 How the neural layer works

The purpose. The bot makes the same small decisions hundreds of times — flag a late check-in? approve this PTO? nudge someone now? Those used to be fixed thresholds someone guessed once. The neural layer learns from what actually happens and from what admins actually decide, so decisions get more consistent, more personal, and more accurate over time.

What it never does. It never messages employees. It never issues strikes on its own — ever. And in the current shadow mode it changes nothing at all: the old rules make every decision, the model just writes down what it would have done so you can compare.

The loop, in 5 steps:

  1. Observe — every decision is logged with ~40 facts about the moment (usual sign-in time, recent pattern, day of week, team situation…).
  2. Learn — twice a day (afternoon + evening) the bot retrains itself on everything logged so far. No button pressing needed.
  3. Score — the live model attaches a probability to each new decision, e.g. "87% chance this person misses today".
  4. Verify — reality labels the predictions (did they show up?), and admin approvals/reversals label the rest. The Review queue tab is where disagreements get settled — every 👍/👎 teaches the next model.
  5. Improve — a new model that clearly beats the current one takes over automatically (only while in shadow; once the model can act, promotion becomes a human click again).

What the numbers mean:

  • Quality score — how well the model separates correct calls from wrong ones on data it never trained on. 90%+ is excellent; 50% is a coin flip.
  • Honesty check (calibration) — if the solid line hugs the dashed line, "80% confident" really comes true ~80% of the time. That honesty is what would make probabilities safe to act on later.
  • Agrees with rules — how often model and current rules reach the same conclusion. High agreement plus better accuracy on the disagreements = the model has earned more trust.
  • Truth collected — the answer key gathered so far. More truth → smarter models. Most of it arrives automatically.

The controls live in Bot Behavior → Neural: the master mode (off / shadow / assist / auto), confidence thresholds, per-decision switches, and hard safety rails (never-DM list, max actions per person per week, new-employee protection, team-outage suppression). Everything is on-demand and reversible — the Audit log records every move.

Where this shows up elsewhere: attendance reports carry a one-line "🧠 attendance risks" summary, and managers/superadmin get a bi-weekly outlook DM. Employees receive nothing from the neural layer.

); } /* ---------- page ---------- */ function MLPage() { const [metrics, setMetrics] = React.useState(null); const [decisions, setDecisions] = React.useState([]); const [models, setModels] = React.useState([]); const [tab, setTab] = React.useState('overview'); const [headFilter, setHeadFilter] = React.useState(''); const [calibHead, setCalibHead] = React.useState('noshow_risk'); const [onlyDisagree, setOnlyDisagree] = React.useState(true); const [busy, setBusy] = React.useState(false); const [toast, setToast] = React.useState(''); const [err, setErr] = React.useState(''); const [guideOpen, setGuideOpen] = React.useState(false); const say = (m) => { setToast(m); setTimeout(() => setToast(''), 4000); }; // Retry transient gateway errors (502/503/504) with backoff. Deploys restart // the single bot container for ~30-60s, during which nginx returns 502 — that // was surfacing as a raw "metrics: 502" the user had to dismiss. Now it rides // the restart out and self-heals instead. const fetchRetry = async (url, opts, tries = 6) => { for (let i = 0; i < tries; i++) { try { const r = await fetch(url, opts); if (r.ok) return r.json(); if (![502, 503, 504].includes(r.status) || i === tries - 1) throw r.status; } catch (e) { if (i === tries - 1) throw e; } await new Promise(res => setTimeout(res, Math.min(1000 * (i + 1), 4000))); } }; const loadAll = React.useCallback(() => { fetchRetry('/api/ml/metrics', { credentials: 'same-origin' }) .then(d => { setMetrics(d); setErr(''); }) .catch(e => setErr(typeof e === 'number' ? `Model service is restarting (${e}) — retrying automatically…` : 'metrics: ' + e)); fetchRetry('/api/ml/models', { credentials: 'same-origin' }) .then(d => setModels(d.models || [])).catch(() => {}); }, []); const loadDecisions = React.useCallback(() => { const params = new URLSearchParams({ limit: '100' }); if (headFilter) params.set('head', headFilter); if (onlyDisagree) params.set('disagree', 'true'); fetch('/api/ml/decisions?' + params, { credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(d => setDecisions(d.decisions || [])).catch(e => setErr('decisions: ' + e)); }, [headFilter, onlyDisagree]); React.useEffect(() => { loadAll(); }, [loadAll]); // Keep trying while metrics haven't loaded (rides out a longer restart) — // stops as soon as they arrive. React.useEffect(() => { if (metrics) return; const t = setInterval(loadAll, 12000); return () => clearInterval(t); }, [metrics, loadAll]); React.useEffect(() => { if (tab === 'review') loadDecisions(); }, [tab, loadDecisions]); const feedback = (id, label) => { fetch('/api/ml/feedback', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision_id: id, label }), }).then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(() => { say('Label saved — that trains the next model. 👌'); loadDecisions(); }) .catch(e => setErr('feedback: ' + e)); }; const trainNow = () => { if (busy) return; setBusy(true); say('Training started — this also runs by itself twice a day.'); fetch('/api/ml/train', { method: 'POST', credentials: 'same-origin' }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(d => { const s = d.summary || {}; if (s.status === 'refused') say('Not enough labels yet: ' + (s.reason || '')); else say('Candidate ' + (s.version || '') + ' trained on ' + (s.n_total || 0) + ' examples.'); loadAll(); }) .catch(e => setErr('train: ' + e)) .finally(() => setBusy(false)); }; const promote = (version) => { if (!window.confirm('Promote ' + version + ' to champion? It starts scoring live decisions immediately (mode still governs whether it can act).')) return; fetch('/api/ml/models/' + encodeURIComponent(version) + '/promote', { method: 'POST', credentials: 'same-origin', }).then(r => r.ok ? r.json() : r.json().then(b => Promise.reject(b.detail || r.status))) .then(() => { say(version + ' is now the live model.'); loadAll(); }) .catch(e => setErr('promote: ' + String(e))); }; const heads = metrics?.heads || {}; const totDecisions = ML_HEADS.reduce((a, h) => a + ((heads[h] || {}).decisions || 0), 0); const scoredHeads = ML_HEADS.filter(h => (heads[h] || {}).scored > 0); const avgAgree = scoredHeads.length ? scoredHeads.reduce((a, h) => a + (heads[h].agreement || 0), 0) / scoredHeads.length : null; const fmtPct = (v) => v === null || v === undefined ? '—' : Math.round(v * 100) + '%'; const srcEntries = Object.entries(metrics?.label_sources || {}); const champion = metrics?.champion; return (
{guideOpen ? setGuideOpen(false)} /> : null} {toast ?
{toast}
: null} {err ?
Error: {err}
: null} {/* header */}
{['overview', 'review', 'models'].map(t => ( ))}
{champion ? <>live model: {champion.version} · retrains itself 2× daily : 'no model yet — observing only, rules in charge'}
{tab === 'overview' && (
{/* plain-language banner */}
Right now: the model is in shadow mode — it predicts every decision but the normal rules still act. It's building a track record you can check below. Nothing is sent to employees. { e.preventDefault(); setGuideOpen(true); }}>Full guide →
0.8 ? '#0ca30c' : '#fab219'} sub={avgAgree === null ? 'appears once scoring starts' : 'on the same live decisions'} />
{/* charts row */}
ML_HEAD_LABELS[h])} values={ML_HEADS.map(h => (heads[h] || {}).decisions || 0)} colors={ML_HEADS.map(h => ML_COLORS[h])} centerLabel="decisions" /> ML_SOURCE_LABELS[k] || k)} values={srcEntries.map(([, v]) => v)} colors={srcEntries.map((_, i) => ML_SOURCE_COLORS[i % ML_SOURCE_COLORS.length])} centerLabel="labels" />
{/* per-head table, compact */}
Decision types at a glance
Each row is one question the model is learning to answer.
{ML_HEADS.map(h => { const m = heads[h] || {}; return ( ); })}
Decision The question it answers WatchedTruth knownAgrees with rules
{ML_HEAD_LABELS[h]} {h === 'uphold_strike' ? (never automatic) : null} {ML_HEAD_PLAIN[h]} {m.decisions ?? 0} {m.labeled ?? 0} 0.8 ? '#0ca30c' : '#fab219' }}> {fmtPct(m.agreement)}
{/* calibration */}
Calibration — {ML_HEAD_LABELS[calibHead]}
The honesty check: when the model says "80% likely", does it come true ~80% of the time? The closer the solid line hugs the dashed one, the more its confidence can be trusted — that's what would make auto-actions safe later.
)} {tab === 'review' && (
Review queue
These are the moments the model and the rules pulled in different directions. Your 👍 (model was right) or 👎 (model was wrong) becomes training data — reviewing a few each week is the single fastest way to make it smarter.
{decisions.length === 0 ? ( ) : decisions.map(d => ( ))}
WhenWho DecisionRules saidModel said Truth so farWas the model right?
{onlyDisagree ? 'No disagreements logged yet — they appear as the model scores live decisions.' : 'No decisions logged yet — they accumulate as the bot works.'}
{(d.ts || '').slice(0, 16).replace('T', ' ')} {d.display_name} {ML_HEAD_LABELS[d.head] || d.head} {d.rule_action ? 'act' : 'no action'} {d.model_p === null || d.model_p === undefined ? '—' : `${Math.round(d.model_p * 100)}% yes`} {(d.labels || []).map((l, i) => {l.label ? '✔' : '✘'})}
)} {tab === 'models' && (
Model registry
The bot trains a fresh candidate twice a day. While in shadow mode, a clearly better one takes over automatically; every version stays on disk, so rolling back is just promoting an older one.
{models.length === 0 ? ( ) : models.map(m => ( ))}
VersionTrainedExamples Quality per decision (test set)Status
No models trained yet — the first one appears after the next scheduled training run.
{m.version} {(m.trained_at || '').slice(0, 10)} {m.n_train} {ML_HEADS.map(h => { const hm = (m.metrics || {})[h]; return hm && hm.pr_auc !== undefined ? {Math.round(hm.pr_auc * 100)}% : null; })} {m.status === 'champion' ? '⭐ live' : m.status} {m.status !== 'champion' ? : null}
)}
); } window.MLPage = MLPage;