POSA_Copyrighter/web/operator-gui/app-stats.js
changukyu fc4e04f65c refactor: app.js 5,428줄을 관심사별 app-*.js 15개로 분할
단일 파일이 프로젝트 상한(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건.
2026-08-08 09:48:52 +09:00

291 lines
5.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)}%`;
}