POSA_Copyrighter/web/operator-gui/app-stats.js
changukyu 2661af86e5 feat: codex 검토 잔여 ③④⑤ + 밀린 부채 — 투입 충돌 표시, 사용/무관 분리, 측정 불가, VERSION
③ 파일명 stem 충돌: 다른 폴더의 같은 이름 파일이 한 건으로 합쳐져 둘째가 조용히 미심사였다.
   id 는 재유도하지 않고(기존 판정 보존) id_collisions() 로 탐지해 bootstrap.intakeConflicts 와
   파일당 1회 감사로 남기고, 콘솔 투입 상태줄에 경고 목록을 띄운다.
④ 행 버튼 '미사용' 이 false_positive 로 가 대표 근거 하나를 고르면 나머지 점수가 지워졌다 →
   '사용'/'무관', 사용 재클릭은 pending(표시 해제, 점수 유지).
⑤ 분모 0 비율은 None → '측정 불가'. 외부 검색이 꺼진 케이스는 점수 0 유지, 한 줄 요약에
   '외부 검색 꺼짐'.

부채: operator-review-workflow 13px 넘침 해소(겹치지 않는 레인 병합) → 다이어그램 5종 첫
REBUILD OK, v6 이전 카드 문구 교정. tests/conftest.py 로 로컬 Ollama 격리(가동 중에도 통과).
루트 VERSION 0.1.0 단일 출처, /health 와 기동 로그에 버전·스코어러 버전.

verify.ps1 (Ollama 가동 중, override 없음): ruff clean, 680 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-25 18:13:09 +09:00

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