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