// 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 `${bars}
`;
}
function formatPercent(rate) {
// 서버는 분모 0 을 null 로 보낸다 — 0% 가 아니라 측정 불가다.
if (rate === null || rate === undefined) return "측정 불가";
return `${Math.round((Number(rate) || 0) * 100)}%`;
}