8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.
중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.
서버는 건드리지 않았다. 자리만 옮겼다.
큐 배지 "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
판정 패널 결정 메모 바로 위에 선례 배너 + [선례 승계] 1타
승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").
판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.
실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.
검증: ruff clean, pytest 622 passed (정적 3건 추가).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
343 lines
10 KiB
JavaScript
343 lines
10 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;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// 동일 이미지에 이미 내려진 판정 요약. 서버는 이 값을 duplicateGroup 에 담아
|
|
// 이미 내려보내고 있었지만, 콘솔은 그것을 큐 배지의 title 속성에만 넣었다 —
|
|
// 마우스를 올려야 보이는 자리라 재제출을 처음부터 다시 심사하는 것을 못 막았다.
|
|
//
|
|
// 승계는 선례가 **만장일치**일 때만 제안한다. conflicting 은 같은 이미지에 서로
|
|
// 다른 판정이 내려졌다는 뜻이고, 그건 자동 처리할 상황이 아니라 사람이 봐야 하는
|
|
// 신호다.
|
|
function priorDecisionSummary(submission) {
|
|
|
|
const group = submission && submission.duplicateGroup;
|
|
|
|
if (!group || !group.size) return null;
|
|
|
|
const priors = group.priorDecisions || [];
|
|
|
|
const decisions = new Set(priors.map((item) => item.decision));
|
|
|
|
const unanimous = !group.conflicting && priors.length > 0 && decisions.size === 1;
|
|
|
|
return {
|
|
|
|
size: group.size,
|
|
|
|
priors,
|
|
|
|
conflicting: Boolean(group.conflicting),
|
|
|
|
inheritable: unanimous ? priors[0].decision : null,
|
|
|
|
};
|
|
|
|
}
|