// Part 11 of 15 of the operator console app script. // // Decision statistics and calibration rendering. // // Load order is significant: these are classic (non-module) scripts sharing one // global scope, and index.html must keep them in the order listed above/below. // Splitting is a pure line-range extraction of the former single app.js. // --- Decision statistics / calibration ---------------------------------- const DECISION_STAT_LABELS = { approved: "승인", held: "보류", rejected: "반려", corrected: "보정", unreviewed: "미심사", }; const BAND_STAT_LABELS = { high: "고위험", medium: "중위험", low: "저위험", failed: "분석실패", pending: "대기", }; async function loadDecisionStats() { const body = document.getElementById("stats-body"); if (!body) return; body.innerHTML = `
통계를 불러오는 중…
`; try { const stats = await apiJson("/api/stats/decisions"); renderDecisionStats(stats); } catch (errorValue) { body.innerHTML = `
통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}
`; } } function calibrationTone(rate, decided) { // Not enough decisions to judge -> neutral, don't cry miscalibration on n=1. if (decided < 5) return "neutral"; if (rate >= 0.4) return "bad"; if (rate >= 0.2) return "warn"; return "good"; } function renderDecisionStats(stats) { const body = document.getElementById("stats-body"); if (!body) return; const cal = stats.calibration || {}; const throughput = stats.throughput || { byDay: [] }; const backlog = stats.backlog || { unreviewed: 0, total: 0 }; const overTone = calibrationTone(cal.highBandApprovalRate || 0, cal.highBandDecided || 0); const underTone = calibrationTone(cal.lowBandRejectionRate || 0, cal.lowBandDecided || 0); const tiles = `
과탐 — 고위험인데 승인 ${formatPercent(cal.highBandApprovalRate)} 고위험 판정 ${cal.highBandDecided || 0}건 기준
미탐 — 저위험인데 반려 ${formatPercent(cal.lowBandRejectionRate)} 저위험 판정 ${cal.lowBandDecided || 0}건 기준
미심사 백로그 ${backlog.unreviewed} 전체 ${backlog.total}건 중
누적 판정 ${throughput.totalDecisions || 0} 감사 로그 기준
`; body.innerHTML = ` ${tiles}

밴드 × 판정 매트릭스

각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.

${renderStatsMatrix(stats)}

최근 결정 추이

최근 14일 일별 판정 건수.

${renderThroughputBars(throughput.byDay || [])}
`; } function renderStatsMatrix(stats) { const bands = stats.bands || []; const decisions = stats.decisions || []; const matrix = stats.matrix || {}; const head = decisions.map((d) => `${escapeHtml(DECISION_STAT_LABELS[d] || d)}`).join(""); const rows = bands .map((band) => { const row = matrix[band] || {}; const total = decisions.reduce((sum, d) => sum + (row[d] || 0), 0); const cells = decisions .map((d) => { const value = row[d] || 0; const strong = value > 0 ? " has-value" : ""; return `${value}`; }) .join(""); return `${escapeHtml(BAND_STAT_LABELS[band] || band)}${cells}${total}`; }) .join(""); return `
${head}${rows}
밴드 \\ 판정합계
`; } function renderThroughputBars(byDay) { if (!byDay.length) { return `
아직 기록된 판정이 없습니다.
`; } const max = Math.max(...byDay.map((entry) => entry.count), 1); const bars = byDay .map((entry) => { const height = Math.round((entry.count / max) * 100); return `
${entry.count} ${escapeHtml(entry.day.slice(5))}
`; }) .join(""); return ``; } function formatPercent(rate) { return `${Math.round((Number(rate) || 0) * 100)}%`; }