단일 파일이 프로젝트 상한(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건.
310 lines
9.3 KiB
JavaScript
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;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|