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

310 lines
9.3 KiB
JavaScript

// Part 4 of 15 of the operator console app script.
//
// Derived reads over state: selected case, risk/source classes, evidence filters.
//
// 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 getSelectedCase() {
return submissions.find((item) => item.id === state.selectedCaseId) || submissions[0];
}
const RISK_BANDS = new Set(["low", "medium", "high", "failed", "pending"]);
function riskClass(riskBand) {
// Allowlist the band so a malformed/hostile server value can't break out of
// the class attribute it is interpolated into (these feed innerHTML).
return `risk-${RISK_BANDS.has(riskBand) ? riskBand : "pending"}`;
}
function sourceClass(source) {
if (source === "naver" || source === "naver_blog" || source === "naver_web") return "source-naver";
if (source === "google" || source === "google_search") return "source-google";
if (source === "llm") return "source-llm";
if (source === "failure") return "source-failure";
return "source-internal";
}
function providerClass(status) {
if (status === "ok" || status === "covered") return "ok";
if (status === "empty") return "skipped";
if (status === "failed") return "failed";
if (status === "disabled" || status === "skipped" || status === "not_run" || status === "pending") return "disabled";
return "skipped";
}
function sourceMatches(submission, source) {
if (source === "all") return true;
if (source === "covered") return submission.evidence.some((item) => item.contributed || item.confidence > 0);
if (source === "failure") {
return Object.values(submission.providerState).includes("failed") || submission.evidence.some((item) => item.source === "failure");
}
if (source === "face") {
return submission.evidence.some((item) => item.source === "face");
}
return submission.evidence.some((item) => item.source === source);
}
function searchableEvidenceItems(submission) {
return window.OperatorEvidenceGuidance.searchableEvidenceItems(submission);
}
function directEvidenceItems(submission) {
return window.OperatorEvidenceGuidance.directEvidenceItems(submission);
}
function hasSearchAttempt(submission) {
return window.OperatorEvidenceGuidance.hasSearchAttempt(submission);
}
function evidenceNeedsFollowup(submission) {
return window.OperatorEvidenceGuidance.evidenceNeedsFollowup(submission);
}
function evidenceFollowupReasons(submission) {
return window.OperatorEvidenceGuidance.evidenceFollowupReasons(submission);
}
function normalizedQuerySeed(submission) {
return window.OperatorEvidenceGuidance.normalizedQuerySeed(submission);
}
function suggestedEvidenceQueries(submission) {
return window.OperatorEvidenceGuidance.suggestedEvidenceQueries(submission);
}
function renderEvidenceNextActions(submission) {
const summary =
suggestedQueryRunSummary && suggestedQueryRunSummary.caseId === submission.id
? `<div class="inline-status" id="suggested-query-run-result">${escapeHtml(suggestedQueryRunSummary.message)}</div>`
: "";
if (!evidenceNeedsFollowup(submission)) return summary;
const queries = suggestedEvidenceQueries(submission);
const reasons = evidenceFollowupReasons(submission);
if (!queries.length) return summary;
return `
<section class="evidence-next-action-panel" aria-label="근거 보강 추천">
<div>
<strong>근거 보강 추천</strong>
<span>추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.</span>
</div>
<ul class="evidence-followup-reasons">
${reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
</ul>
<div class="suggested-query-list">
${queries
.map(
(query) => `
<span class="suggested-query-item">
<button class="row-action" type="button" data-suggested-query="${escapeHtml(query)}" data-suggested-provider="naver">
${escapeHtml(query)}
</button>
<button class="row-action" type="button" data-run-suggested-query="${escapeHtml(query)}">바로 실행</button>
</span>
`,
)
.join("")}
</div>
<div class="suggested-query-actions">
<button class="row-action" type="button" id="run-all-suggested-queries">모두 실행</button>
</div>
<div id="suggested-query-run-status" class="inline-status" aria-live="polite"></div>
${summary}
</section>
`;
}
function applySuggestedQuery(button) {
const queryInput = document.getElementById("manual-query");
const providerSelect = document.getElementById("manual-query-provider");
const status = document.getElementById("manual-query-status");
const provider = normalizeManualSearchProvider(button.dataset.suggestedProvider);
queryInput.value = button.dataset.suggestedQuery || "";
providerSelect.value = provider;
switchWorkbenchTab("queries");
queryInput.focus();
if (status) status.textContent = "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요.";
}
let isSuggestedQueryRunActive = false;
async function executeSuggestedQueries(queries) {
if (isSuggestedQueryRunActive) return;
const submission = getSelectedCase();
if (!submission || !queries.length) return;
const statusTarget = document.getElementById("suggested-query-run-status");
const naver = providers.find((provider) => provider.id === "naver");
if (!naver || !naver.enabled) {
if (statusTarget) statusTarget.textContent = `${providerLabels.naver} 외부 검색 tool이 비활성입니다.`;
return;
}
isSuggestedQueryRunActive = true;
try {
const results = [];
for (const [index, query] of queries.entries()) {
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
try {
const review = await apiJson("/api/search/manual", {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
});
// queryHistory.count is the number of evidence rows the search produced,
// which includes the skip/failure/"no results" markers — so a query that
// was refused still reported count>=1 and was announced as "완료(근거 1건)"
// while the skip branch below could never run. Count real evidence here
// and let a failure row speak for itself.
const matching = (review.evidence || []).filter((item) => item.query === query);
const skipped = matching.find((item) => item.source === "failure");
const resultCount = matching.filter(
(item) =>
item.source !== "failure" &&
!/returned no results/i.test(String(item.title || "")),
).length;
if (skipped) {
results.push(`"${query}" ${formatReason(skipped.title || "건너뜀")}`);
} else if (resultCount === 0) {
results.push(`"${query}" 결과 없음`);
} else {
results.push(`"${query}" 완료(근거 ${resultCount}건)`);
}
} catch (errorValue) {
results.push(`"${query}" 실패: ${errorValue.message}`);
}
}
suggestedQueryRunSummary = {
caseId: submission.id,
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
};
try {
await refreshFromApi();
} catch (refreshError) {
// Don't let a refresh failure become an unhandled rejection that leaves
// the status stuck at "실행 중…". Re-resolve the element (a render may have
// replaced it) and report honestly alongside the collected results.
const liveStatus = document.getElementById("suggested-query-run-status");
if (liveStatus) {
liveStatus.textContent = `새로고침 실패: ${refreshError.message} · 실행 결과: ${results.join(" · ")}`;
}
}
} finally {
isSuggestedQueryRunActive = false;
}
}
function filteredSubmissions() {
const query = state.filters.query.trim().toLowerCase();
const filtered = submissions.filter((submission) => {
const riskOk = state.filters.risk === "all" || submission.riskBand === state.filters.risk;
const sourceOk = sourceMatches(submission, state.filters.source);
const decisionOk = state.filters.decision === "all" || submission.decisionStatus === state.filters.decision;
const queryOk =
!query ||
submission.id.toLowerCase().includes(query) ||
submission.title.toLowerCase().includes(query) ||
submission.reasons.join(" ").toLowerCase().includes(query);
return riskOk && sourceOk && decisionOk && queryOk;
});
return filtered.sort((left, right) => {
if (state.filters.sort === "newest") return right.submittedEpoch - left.submittedEpoch;
if (state.filters.sort === "oldest") return left.submittedEpoch - right.submittedEpoch;
if (state.filters.sort === "analysis_failure") {
return Number(right.riskBand === "failed") - Number(left.riskBand === "failed");
}
if (state.filters.sort === "provider_failure") {
return Number(sourceMatches(right, "failure")) - Number(sourceMatches(left, "failure"));
}
return right.riskScore - left.riskScore;
});
}