단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며 어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본 app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B). 클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧 계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록 2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라 순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정: - test_index_loads_every_app_part_exactly_once_in_declared_order 각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가 전부 배선됐는지 + 구 app.js 가 남아있지 않은지 - test_app_parts_stay_within_the_file_size_budget (800줄 상한) 정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는 '어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드 순서대로 이어붙여 읽는 _read_app() 으로 바꿨다. 검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함). 운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200, 새로고침 후 콘솔 메시지 0건.
291 lines
5.7 KiB
JavaScript
291 lines
5.7 KiB
JavaScript
// 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 = `<div class="empty-state">통계를 불러오는 중…</div>`;
|
||
|
||
try {
|
||
|
||
const stats = await apiJson("/api/stats/decisions");
|
||
|
||
renderDecisionStats(stats);
|
||
|
||
} catch (errorValue) {
|
||
|
||
body.innerHTML = `<div class="empty-state">통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}</div>`;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
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 = `
|
||
|
||
<div class="stat-tiles">
|
||
|
||
<article class="stat-tile tone-${overTone}">
|
||
|
||
<span class="stat-tile-label">과탐 — 고위험인데 승인</span>
|
||
|
||
<strong class="stat-tile-value">${formatPercent(cal.highBandApprovalRate)}</strong>
|
||
|
||
<span class="stat-tile-sub">고위험 판정 ${cal.highBandDecided || 0}건 기준</span>
|
||
|
||
</article>
|
||
|
||
<article class="stat-tile tone-${underTone}">
|
||
|
||
<span class="stat-tile-label">미탐 — 저위험인데 반려</span>
|
||
|
||
<strong class="stat-tile-value">${formatPercent(cal.lowBandRejectionRate)}</strong>
|
||
|
||
<span class="stat-tile-sub">저위험 판정 ${cal.lowBandDecided || 0}건 기준</span>
|
||
|
||
</article>
|
||
|
||
<article class="stat-tile">
|
||
|
||
<span class="stat-tile-label">미심사 백로그</span>
|
||
|
||
<strong class="stat-tile-value">${backlog.unreviewed}</strong>
|
||
|
||
<span class="stat-tile-sub">전체 ${backlog.total}건 중</span>
|
||
|
||
</article>
|
||
|
||
<article class="stat-tile">
|
||
|
||
<span class="stat-tile-label">누적 판정</span>
|
||
|
||
<strong class="stat-tile-value">${throughput.totalDecisions || 0}</strong>
|
||
|
||
<span class="stat-tile-sub">감사 로그 기준</span>
|
||
|
||
</article>
|
||
|
||
</div>
|
||
|
||
`;
|
||
|
||
|
||
|
||
body.innerHTML = `
|
||
|
||
${tiles}
|
||
|
||
<div class="stats-columns">
|
||
|
||
<section class="stats-card">
|
||
|
||
<h2>밴드 × 판정 매트릭스</h2>
|
||
|
||
<p class="muted small">각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.</p>
|
||
|
||
${renderStatsMatrix(stats)}
|
||
|
||
</section>
|
||
|
||
<section class="stats-card">
|
||
|
||
<h2>최근 결정 추이</h2>
|
||
|
||
<p class="muted small">최근 14일 일별 판정 건수.</p>
|
||
|
||
${renderThroughputBars(throughput.byDay || [])}
|
||
|
||
</section>
|
||
|
||
</div>
|
||
|
||
`;
|
||
|
||
}
|
||
|
||
|
||
|
||
function renderStatsMatrix(stats) {
|
||
|
||
const bands = stats.bands || [];
|
||
|
||
const decisions = stats.decisions || [];
|
||
|
||
const matrix = stats.matrix || {};
|
||
|
||
const head = decisions.map((d) => `<th scope="col">${escapeHtml(DECISION_STAT_LABELS[d] || d)}</th>`).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 `<td class="matrix-cell${strong}">${value}</td>`;
|
||
|
||
})
|
||
|
||
.join("");
|
||
|
||
return `<tr><th scope="row"><span class="risk-badge ${riskClass(band)}">${escapeHtml(BAND_STAT_LABELS[band] || band)}</span></th>${cells}<td class="matrix-total">${total}</td></tr>`;
|
||
|
||
})
|
||
|
||
.join("");
|
||
|
||
return `
|
||
|
||
<div class="table-shell">
|
||
|
||
<table class="stats-matrix">
|
||
|
||
<thead><tr><th scope="col">밴드 \\ 판정</th>${head}<th scope="col">합계</th></tr></thead>
|
||
|
||
<tbody>${rows}</tbody>
|
||
|
||
</table>
|
||
|
||
</div>
|
||
|
||
`;
|
||
|
||
}
|
||
|
||
|
||
|
||
function renderThroughputBars(byDay) {
|
||
|
||
if (!byDay.length) {
|
||
|
||
return `<div class="empty-state">아직 기록된 판정이 없습니다.</div>`;
|
||
|
||
}
|
||
|
||
const max = Math.max(...byDay.map((entry) => entry.count), 1);
|
||
|
||
const bars = byDay
|
||
|
||
.map((entry) => {
|
||
|
||
const height = Math.round((entry.count / max) * 100);
|
||
|
||
return `
|
||
|
||
<div class="throughput-bar" title="${escapeHtml(entry.day)} · ${entry.count}건">
|
||
|
||
<div class="throughput-bar-fill" style="height:${height}%"></div>
|
||
|
||
<span class="throughput-bar-count">${entry.count}</span>
|
||
|
||
<span class="throughput-bar-day">${escapeHtml(entry.day.slice(5))}</span>
|
||
|
||
</div>
|
||
|
||
`;
|
||
|
||
})
|
||
|
||
.join("");
|
||
|
||
return `<div class="throughput-chart" role="img" aria-label="일별 판정 건수">${bars}</div>`;
|
||
|
||
}
|
||
|
||
|
||
|
||
function formatPercent(rate) {
|
||
|
||
return `${Math.round((Number(rate) || 0) * 100)}%`;
|
||
|
||
}
|
||
|
||
|