// 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 ? `
${escapeHtml(suggestedQueryRunSummary.message)}
` : ""; if (!evidenceNeedsFollowup(submission)) return summary; const queries = suggestedEvidenceQueries(submission); const reasons = evidenceFollowupReasons(submission); if (!queries.length) return summary; return `
근거 보강 추천 추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.
${queries .map( (query) => ` `, ) .join("")}
${summary}
`; } 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; }); }