POSA_Copyrighter/web/operator-gui/app-render-panels.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

364 lines
14 KiB
JavaScript

// Part 7 of 15 of the operator console app script.
//
// Knowledge, candidates, corrections, providers, audit panels and renderAll.
//
// 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.
function filteredKnowledgeEntries() {
const query = knowledgeFilters.query.trim().toLowerCase();
return knowledgeEntries.filter((entry) => {
const typeOk = knowledgeFilters.type === "all" || entry.type === knowledgeFilters.type;
const statusOk = knowledgeFilters.status === "all" || (entry.entryStatus || "confirmed") === knowledgeFilters.status;
const activeOk = knowledgeFilters.active === "all" || (knowledgeFilters.active === "active") === Boolean(entry.active);
const haystack = [entry.name, ...(entry.aliases || []), ...(entry.keywords || [])].join(" ").toLowerCase();
const queryOk = !query || haystack.includes(query);
return typeOk && statusOk && activeOk && queryOk;
});
}
function renderKnowledgeBase() {
const entries = filteredKnowledgeEntries();
document.getElementById("knowledge-list").innerHTML = entries.length
? entries
.map((entry) => {
const aliases = entry.aliases || [];
const keywords = entry.keywords || [];
const entryStatus = entry.entryStatus || "confirmed";
const statusMeta =
entryStatus === "watchlist"
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
: "";
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
const lifecycleActions =
entryStatus === "watchlist"
? `
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
`
: entryStatus === "excluded"
? ""
: entry.active
? `<button class="row-action danger" type="button" data-deactivate-kb="${escapeHtml(entry.id)}">비활성</button>`
: `<button class="row-action" type="button" data-reactivate-kb="${escapeHtml(entry.id)}">재활성</button>`;
const editForm =
editingKnowledgeEntryId === entry.id
? `
<form class="knowledge-edit-form" data-knowledge-edit-form="${escapeHtml(entry.id)}">
<label>
<span>별칭</span>
<input name="aliases" type="text" value="${escapeHtml(aliases.join(", "))}">
</label>
<label>
<span>검색 키워드</span>
<input name="keywords" type="text" value="${escapeHtml(keywords.join(", "))}">
</label>
<label class="wide-field">
<span>정책 메모</span>
<textarea name="memo" rows="3">${escapeHtml(entry.memo || "")}</textarea>
</label>
<div class="knowledge-edit-actions">
<button class="primary-action" type="submit">저장</button>
<button class="secondary-action" type="button" data-cancel-edit-kb="${escapeHtml(entry.id)}">취소</button>
</div>
</form>
`
: "";
return `
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
${
entry.imageAsset
? `<img class="knowledge-thumb" src="${escapeHtml(entry.imageAsset)}" alt="${escapeHtml(entry.name)} 참조 이미지">`
: `<div class="knowledge-thumb empty" aria-hidden="true"></div>`
}
<div class="knowledge-main">
<div class="row-title knowledge-title">
<span>${escapeHtml(entry.name)}</span>
</div>
<div class="knowledge-detail-line">
<div class="knowledge-chip-row">
<span class="provenance-chip ${escapeHtml(entry.provenance)}">${escapeHtml(formatProvenance(entry.provenance))}</span>
<span class="watchlist-chip ${escapeHtml(entryStatus)}">${escapeHtml(formatKnowledgeStatus(entryStatus))}</span>
</div>
<div class="row-meta knowledge-meta">
<span>${escapeHtml(formatKnowledgeType(entry.type))}</span>
<span>별칭: ${escapeHtml(aliases.join(", ") || "-")}</span>
<span>키워드: ${escapeHtml(keywords.join(", ") || "-")}</span>
<span>샘플 지문 ${(entry.sampleFingerprints || []).length}</span>
${statusMeta}
${memoInline}
</div>
</div>
${memoBlock}
${editForm}
</div>
<div class="knowledge-actions">
<button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
${lifecycleActions}
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
renderCollectionCandidates();
}
function renderCollectionCandidates() {
const target = document.getElementById("collection-candidates");
if (!target) return;
const visibleCandidates = visibleCollectionCandidates();
target.innerHTML = visibleCandidates.length
? visibleCandidates
.map((candidate) => {
const promoted = candidate.status === "promoted";
const canPromote = !promoted && (candidate.sampleFingerprints || []).length;
return `
<article class="candidate-card ${promoted ? "promoted" : ""}">
<label class="candidate-select">
<input
type="checkbox"
data-collection-candidate-id="${escapeHtml(candidate.id)}"
${canPromote ? "" : "disabled"}
>
<span class="sr-only">${escapeHtml(candidate.title || candidate.query)} 선택</span>
</label>
${
candidate.imageAsset
? `<img class="candidate-thumb" src="${escapeHtml(candidate.imageAsset)}" alt="${escapeHtml(candidate.title || candidate.query)} 후보 이미지">`
: `<div class="candidate-thumb empty" aria-hidden="true"></div>`
}
<div class="candidate-main">
<div class="row-title">
<span class="source-chip ${sourceClass(candidate.provider)}">${escapeHtml(providerLabels[candidate.provider] || candidate.provider || "검색")}</span>
<span>${escapeHtml(candidate.title || candidate.query)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(candidate.query)}</span>
<span>${escapeHtml(formatCandidateSourceType(candidate.sourceCandidateType))}</span>
<span>순위 ${escapeHtml(candidate.rank || "-")}</span>
<span>${escapeHtml(formatCandidateStatus(candidate.status))}</span>
</div>
${
candidate.sourceUrl
? `<a class="evidence-link" href="${escapeHtml(safeUrl(candidate.sourceUrl))}" target="_blank" rel="noreferrer">출처 열기</a>`
: ""
}
</div>
<div class="candidate-actions">
<button class="row-action" type="button" data-promote-candidate="${escapeHtml(candidate.id)}" ${canPromote ? "" : "disabled"}>
${promoted ? "반영됨" : "기준 DB 반영"}
</button>
<button class="row-action secondary-action" type="button" data-dismiss-candidate="${escapeHtml(candidate.id)}">무시</button>
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">검색어와 제공자를 선택하면 기준 DB 후보가 표시됩니다.</div>`;
}
function visibleCollectionCandidates() {
return collectionCandidates.filter(
(candidate) =>
candidate.status === "candidate" &&
candidate.query === state.currentCollectionQuery &&
(!state.currentCollectionProvider || candidate.provider === state.currentCollectionProvider),
);
}
function renderCorrections() {
document.getElementById("corrections-list").innerHTML = corrections
.map(
(correction) => `
<article class="correction-row">
<div>
<div class="row-title">
<span class="risk-badge risk-failed">보정</span>
<span>${escapeHtml(correction.decisionId)} · ${escapeHtml(correction.submissionId)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(correction.previous)} -> ${escapeHtml(correction.current)}</span>
<span>${escapeHtml(correction.operator)}</span>
<span>${escapeHtml(correction.timestamp)}</span>
</div>
<p class="muted small">${escapeHtml(correction.reason)}</p>
<div class="chip-row">
${(correction.derivedEntries || [])
.map(
(entry) =>
`<span class="provider-chip ${entry.active ? "failed" : "disabled"}">${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}</span>`,
)
.join("")}
</div>
</div>
<button class="row-action" type="button" data-deactivate-correction="${escapeHtml(correction.id)}">파생 항목 비활성</button>
</article>
`,
)
.join("");
}
function renderProviderEnvStatus(provider) {
const requiredEnv = Array.isArray(provider.requiredEnv) ? provider.requiredEnv : [];
if (!requiredEnv.length) return "";
const configuredEnv = provider.configuredEnv || {};
const missing = requiredEnv.filter((key) => !configuredEnv[key]);
if (!missing.length) {
return `<span class="muted small">환경 변수 확인됨: ${escapeHtml(requiredEnv.join(", "))}</span>`;
}
return `<span class="muted small">누락된 환경 변수: ${escapeHtml(missing.join(", "))}</span>`;
}
function visibleProviderControls() {
return providers.filter((provider) => !retiredProviderIds.has(provider.id));
}
function renderProviderControls() {
document.getElementById("providers-list").innerHTML = visibleProviderControls()
.map((provider) => {
const percent = provider.quota ? Math.min(100, Math.round((provider.usage / provider.quota) * 100)) : 0;
const providerName = formatProviderName(provider);
const envStatus = renderProviderEnvStatus(provider);
return `
<article class="provider-row">
<div class="provider-summary">
<div class="row-title">
<span class="status-dot ${provider.enabled ? "ok" : "bad"}" aria-hidden="true"></span>
<span>${escapeHtml(providerName)}</span>
</div>
<div class="row-meta">
<span>${provider.enabled ? "활성" : "중지"}</span>
<span>${escapeHtml(formatReason(provider.compliance))}</span>
<span>최근 성공: ${escapeHtml(formatReason(provider.lastSuccess))}</span>
<span>최근 실패: ${escapeHtml(formatReason(provider.lastFailure))}</span>
</div>
<span class="muted small">${escapeHtml(formatReason(provider.boundary))}</span>
${envStatus}
</div>
<div>
<div class="row-meta"><span>${provider.usage} / ${provider.quota == null ? "무제한" : provider.quota}</span><span>${percent}%</span></div>
<div class="quota-meter" aria-label="${escapeHtml(providerName)} 사용량 ${percent}%"><span style="width: ${percent}%"></span></div>
</div>
<div class="provider-actions">
<button type="button" data-toggle-provider="${escapeHtml(provider.id)}">${provider.enabled ? "중지" : "활성"}</button>
<button type="button" data-provider-retry="${escapeHtml(provider.id)}">실패 재시도</button>
</div>
</article>
`;
})
.join("");
}
function renderAuditLog() {
document.getElementById("audit-body").innerHTML = auditEvents
.map(
(event) => `
<tr>
<td>${escapeHtml(event.timestamp)}</td>
<td>${escapeHtml(event.actor)}</td>
<td>${escapeHtml(formatReason(event.event))}</td>
<td>${escapeHtml(formatReason(event.object))}</td>
<td>${escapeHtml(formatReason(event.change))}</td>
</tr>
`,
)
.join("");
}
function renderProviderPulse() {
const visibleProviders = visibleProviderControls();
const failed = visibleProviders.filter((provider) => provider.enabled && provider.lastFailure !== "없음").length;
const disabled = visibleProviders.filter((provider) => !provider.enabled).length;
const className = disabled || failed ? "warn" : "ok";
document.getElementById("provider-pulse").innerHTML = `
<span class="status-dot ${className}" aria-hidden="true"></span>
<span>외부 검색 tool ${visibleProviders.filter((provider) => provider.enabled).length}/${visibleProviders.length} 활성 · 실패 ${failed}</span>
`;
}
function renderAll() {
renderOperatorSearchProviderOptions();
renderProviderPulse();
renderCoverageTabs();
renderImportStatus();
renderQueue();
renderCaseReview();
renderEvidenceSearch();
renderKnowledgeBase();
labelCollectionCandidateActions();
renderCorrections();
renderProviderControls();
renderAuditLog();
renderMemoPresets();
renderBulkDecisionCount();
renderReviewProgress();
}
function labelCollectionCandidateActions() {
document.querySelectorAll("[data-promote-candidate]").forEach((button) => {
const candidate = collectionCandidates.find((item) => item.id === button.dataset.promoteCandidate);
button.textContent = candidate?.status === "promoted" ? "반영됨" : "기준 DB 반영";
});
}