diff --git a/tests/operator_gui/test_static_workbench.py b/tests/operator_gui/test_static_workbench.py
index 95be6fb..9d9c2e5 100644
--- a/tests/operator_gui/test_static_workbench.py
+++ b/tests/operator_gui/test_static_workbench.py
@@ -8,7 +8,27 @@ ROOT = Path(__file__).resolve().parents[2]
APP_DIR = ROOT / "web" / "operator-gui"
INDEX = APP_DIR / "index.html"
STYLES = APP_DIR / "styles.css"
-APP_JS = APP_DIR / "app.js"
+# The former single app.js, split by concern. Order is load-bearing (classic
+# scripts sharing one global scope), so this list doubles as the expectation the
+# index.html ordering test asserts against.
+APP_SCRIPT_NAMES = [
+ "app-state.js",
+ "app-api.js",
+ "app-format.js",
+ "app-selectors.js",
+ "app-render-queue.js",
+ "app-render-evidence.js",
+ "app-render-panels.js",
+ "app-navigation.js",
+ "app-keyboard-batch.js",
+ "app-decision-tools.js",
+ "app-stats.js",
+ "app-submissions.js",
+ "app-collections.js",
+ "app-admin.js",
+ "app-bootstrap.js",
+]
+APP_SCRIPTS = [APP_DIR / name for name in APP_SCRIPT_NAMES]
OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js"
SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js"
EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js"
@@ -33,10 +53,20 @@ def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
+def _read_app() -> str:
+ """The whole operator app script, whichever part a given snippet now lives in.
+
+ These specs assert that a behavior is present in the console, not which file
+ holds it, so they read the parts as one text in load order.
+ """
+ return "\n".join(_read(path) for path in APP_SCRIPTS)
+
+
def test_operator_workbench_files_exist():
assert INDEX.exists()
assert STYLES.exists()
- assert APP_JS.exists()
+ for path in APP_SCRIPTS:
+ assert path.exists(), path.name
assert OPERATOR_LABELS_JS.exists()
assert SUBMISSION_IMPORT_JS.exists()
assert EVIDENCE_GUIDANCE_JS.exists()
@@ -63,7 +93,7 @@ def test_operator_gui_text_does_not_include_mojibake_fragments():
"?",
]
- for path in [INDEX, APP_JS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
+ for path in [INDEX, *APP_SCRIPTS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
text = _read(path)
for fragment in suspect_fragments:
assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}"
@@ -154,7 +184,7 @@ def test_workbench_shell_exposes_all_internal_operator_views():
def test_static_app_models_core_review_operations():
- script = _read(APP_JS)
+ script = _read_app()
for state_name in [
"submissions",
@@ -193,18 +223,56 @@ def test_static_app_models_core_review_operations():
assert f"function {operation}" in script
+def test_index_loads_every_app_part_exactly_once_in_declared_order():
+ """The split is only safe while the load order holds.
+
+ The parts are classic scripts sharing one global scope: app-state.js declares
+ the state the rest read, and app-bootstrap.js is the only part that executes
+ anything at load. Reordering, dropping, or adding a part without updating
+ index.html would break the console at runtime but leave every content-level
+ assertion in this module green, so pin the sequence itself.
+ """
+ html = _read(INDEX)
+
+ positions = []
+ for name in APP_SCRIPT_NAMES:
+ tag = f''
+ assert html.count(tag) == 1, f"{name} must be loaded exactly once"
+ positions.append(html.index(tag))
+
+ assert positions == sorted(positions), "app-*.js tags are out of declared order"
+ assert APP_SCRIPT_NAMES[-1] == "app-bootstrap.js", "the part that starts the app must load last"
+
+ # No part may be orphaned: every app-*.js on disk has to be wired into the page.
+ on_disk = sorted(path.name for path in APP_DIR.glob("app-*.js"))
+ assert on_disk == sorted(APP_SCRIPT_NAMES)
+
+ # The pre-split single file must be gone, or a stale copy would silently
+ # double-define everything if anyone re-added a tag for it.
+ assert not (APP_DIR / "app.js").exists()
+
+
+def test_app_parts_stay_within_the_file_size_budget():
+ """800 lines per file is the project's stated ceiling (coding-style rules)."""
+ oversized = {
+ path.name: len(_read(path).splitlines()) for path in APP_SCRIPTS if len(_read(path).splitlines()) > 800
+ }
+ assert not oversized, f"app parts over the 800-line budget: {oversized}"
+
+
def test_operator_gui_extracts_submission_import_helpers_from_main_app():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
labels = _read(OPERATOR_LABELS_JS)
helpers = _read(SUBMISSION_IMPORT_JS)
guidance = _read(EVIDENCE_GUIDANCE_JS)
search = _read(OPERATOR_SEARCH_JS)
+ first_app_part = f'src="{APP_SCRIPT_NAMES[0]}"'
assert html.index('src="operator-labels.js"') < html.index('src="submission-import.js"')
- assert html.index('src="submission-import.js"') < html.index('src="app.js"')
- assert html.index('src="evidence-guidance.js"') < html.index('src="app.js"')
- assert html.index('src="operator-search.js"') < html.index('src="app.js"')
+ assert html.index('src="submission-import.js"') < html.index(first_app_part)
+ assert html.index('src="evidence-guidance.js"') < html.index(first_app_part)
+ assert html.index('src="operator-search.js"') < html.index(first_app_part)
assert "OperatorLabels" in labels
assert "riskLabels" in labels
assert "providerLabels" in labels
@@ -231,7 +299,7 @@ def test_operator_gui_extracts_submission_import_helpers_from_main_app():
def test_operator_gui_does_not_boot_with_demo_review_data():
- script = _read(APP_JS)
+ script = _read_app()
for collection in [
"submissions",
@@ -303,7 +371,7 @@ def test_ui_overhaul_final_audit_has_no_overflow_and_required_screenshots():
def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
html = _read(INDEX)
styles = _read(STYLES)
- script = _read(APP_JS)
+ script = _read_app()
assert 'id="coverage-tabs"' in html
assert 'id="search-coverage"' not in html
@@ -318,7 +386,7 @@ def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns():
html = _read(INDEX)
styles = _read(STYLES)
- script = _read(APP_JS)
+ script = _read_app()
assert "queue-grid" in html
assert "queue-row" in script
@@ -349,7 +417,7 @@ def test_visual_overhaul_removes_obsolete_header_cards_and_fixed_table_widths():
def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert 'id="workbench-view"' in html
@@ -387,7 +455,7 @@ def test_case_workbench_tabs_do_not_share_the_same_review_layout():
def test_case_decision_controls_float_while_reviewing_evidence():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert "floating-decision-panel" in html
@@ -404,7 +472,7 @@ def test_case_decision_controls_float_while_reviewing_evidence():
def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert "knowledge-main" in script
@@ -423,7 +491,7 @@ def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
def test_correction_history_lives_inside_knowledge_database_panel():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert 'data-knowledge-tab="collect"' in html
@@ -460,7 +528,7 @@ def test_knowledge_collection_and_registered_entries_are_separate_tabs():
def test_safety_rules_are_visible_in_ui_contract():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
assert 'id="decision-memo"' in html
assert 'id="manual-query-provider"' in html
@@ -476,7 +544,7 @@ def test_safety_rules_are_visible_in_ui_contract():
def test_provider_quota_zero_does_not_render_nan_meter():
- script = _read(APP_JS)
+ script = _read_app()
assert "provider.quota ? Math.min" in script
assert "configuredEnv" in script
@@ -484,7 +552,7 @@ def test_provider_quota_zero_does_not_render_nan_meter():
def test_query_history_rerun_preserves_original_search_provider():
- script = _read(APP_JS)
+ script = _read_app()
search = _read(OPERATOR_SEARCH_JS)
assert "data-rerun-provider" in script
@@ -497,7 +565,7 @@ def test_query_history_rerun_preserves_original_search_provider():
def test_operator_gui_exposes_submission_reload_action():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
assert 'id="submission-folder"' in html
assert 'id="submission-image"' in html
@@ -519,7 +587,7 @@ def test_operator_gui_exposes_submission_reload_action():
def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
- script = _read(APP_JS) + _read(SUBMISSION_IMPORT_JS)
+ script = _read_app() + _read(SUBMISSION_IMPORT_JS)
for required_copy in [
"제출 폴더를 읽는 중입니다.",
@@ -538,7 +606,7 @@ def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
- script = _read(APP_JS)
+ script = _read_app()
assert "function selectedBulkSubmissionIds" in script
assert 'input[data-bulk-id]:checked' in script
@@ -548,7 +616,7 @@ def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
assert 'id="knowledge-image"' in html
assert 'id="knowledge-image-name"' in html
@@ -563,7 +631,7 @@ def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
def test_operator_gui_exposes_keyword_candidate_collection_workflow():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
search = _read(OPERATOR_SEARCH_JS)
assert 'id="candidate-collection-form"' in html
@@ -612,7 +680,7 @@ def test_operator_gui_exposes_keyword_candidate_collection_workflow():
def test_google_custom_search_is_exposed_as_operator_text_query_choice():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
search = _read(OPERATOR_SEARCH_JS)
labels = _read(OPERATOR_LABELS_JS)
@@ -638,7 +706,7 @@ def test_audit_target_and_change_columns_have_equal_widths():
def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column():
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert "provider-strip" in script
@@ -655,7 +723,7 @@ def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column
def test_evidence_operator_status_actions_are_binary_use_or_ignore():
- script = _read(APP_JS)
+ script = _read_app()
assert '["used_for_judgment", "사용"]' in script
assert '["ignored", "미사용"]' in script
@@ -667,7 +735,7 @@ def test_evidence_operator_status_actions_are_binary_use_or_ignore():
def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
for english_label in [
"Image Rights Operator Console",
@@ -702,7 +770,7 @@ def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
def test_operator_gui_renders_search_result_links_and_thumbnails():
- script = _read(APP_JS)
+ script = _read_app()
assert "renderEvidenceLink" in script
assert "thumbnailUrl" in script
@@ -725,7 +793,7 @@ def test_operator_gui_renders_search_result_links_and_thumbnails():
def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert "renderEvidenceSummary" in script
@@ -740,7 +808,7 @@ def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
guidance = _read(EVIDENCE_GUIDANCE_JS)
combined_script = script + guidance
styles = _read(STYLES)
@@ -766,7 +834,7 @@ def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
- script = _read(APP_JS)
+ script = _read_app()
assert "faceCropSearch" in script
assert "face_web" in script
@@ -775,7 +843,7 @@ def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
def test_operator_gui_exposes_evidence_status_and_watchlist_controls():
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert "/api/evidence/" in script
@@ -796,7 +864,7 @@ def test_visual_assets_are_referenced_for_review_images():
assets_dir = APP_DIR / "assets"
assert (assets_dir / "case-portrait.svg").exists()
- assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read(APP_JS)
+ assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read_app()
for orphan_asset in [
"case-character.svg",
@@ -807,7 +875,7 @@ def test_visual_assets_are_referenced_for_review_images():
def test_suggested_queries_support_one_click_and_batch_execution():
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert "executeSuggestedQueries" in script
@@ -823,7 +891,7 @@ def test_suggested_queries_support_one_click_and_batch_execution():
def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
assert 'id="knowledge-search"' in registered_panel
@@ -843,7 +911,7 @@ def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
def test_workbench_shows_detected_face_crop_strip():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
assert 'id="face-crop-strip"' in html
assert "faceCrops" in script
@@ -856,7 +924,7 @@ def test_workbench_shows_detected_face_crop_strip():
def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
html = _read(INDEX)
- script = _read(APP_JS)
+ script = _read_app()
styles = _read(STYLES)
assert 'id="rerun-diff-summary"' in html
diff --git a/tests/rights_filter/server/test_http_app.py b/tests/rights_filter/server/test_http_app.py
index 9ec4d3d..4ea1b34 100644
--- a/tests/rights_filter/server/test_http_app.py
+++ b/tests/rights_filter/server/test_http_app.py
@@ -1179,7 +1179,12 @@ def test_http_server_runs_auto_search_for_submission(tmp_path: Path):
def test_operator_gui_uses_api_bootstrap():
- app_js = Path("web/operator-gui/app.js").read_text(encoding="utf-8")
+ # app.js was split into ordered app-*.js parts; this spec cares that the
+ # console bootstraps off the API, not which part holds the call.
+ app_js = "
+".join(
+ path.read_text(encoding="utf-8") for path in sorted(Path("web/operator-gui").glob("app-*.js"))
+ )
assert "/api/bootstrap" in app_js
assert "refreshFromApi" in app_js
diff --git a/web/operator-gui/app-admin.js b/web/operator-gui/app-admin.js
new file mode 100644
index 0000000..4cdc767
--- /dev/null
+++ b/web/operator-gui/app-admin.js
@@ -0,0 +1,282 @@
+// Part 14 of 15 of the operator console app script.
+//
+// Knowledge/correction/provider/evidence-status admin actions.
+//
+// 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 addKnowledgeEntryFromCase() {
+
+ // The control is labelled "주의 후보 보기" (view watchlist candidates), so show
+ // the registered list filtered to watchlist — it previously opened the blank
+ // manual-registration form, which is a different action entirely.
+
+ switchView("knowledge");
+
+ switchKnowledgeTab("registered");
+
+ knowledgeFilters.status = "watchlist";
+
+ const statusFilter = document.getElementById("knowledge-status-filter");
+
+ if (statusFilter) statusFilter.value = "watchlist";
+
+ renderAll();
+
+}
+
+
+
+async function deactivateCorrectionEntry(correctionId) {
+ const correction = corrections.find((item) => item.id === correctionId);
+ if (!correction) return;
+
+ // Deactivate the derived knowledge entries through the real backend endpoint
+ // so the change is persisted and audited server-side. The previous version
+ // only mutated local state and appended a fake audit event, so the action was
+ // silently discarded on the next refreshFromApi().
+ // Only entries that are still active need deactivating. Skipping already-
+ // inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
+ // abort the whole loop on the first inactive entry.
+ const entryIds = (correction.derivedEntries || [])
+ .filter((entry) => entry && entry.id && entry.active)
+ .map((entry) => entry.id);
+ if (!entryIds.length) {
+ showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
+ return;
+ }
+
+ const failures = [];
+ try {
+ for (const entryId of entryIds) {
+ try {
+ await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
+ method: "POST",
+ body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
+ });
+ } catch (_perEntryError) {
+ // One entry failing must not strand the rest.
+ failures.push(entryId);
+ }
+ }
+ } finally {
+ // Always refresh so the UI reflects whatever was actually persisted, even on
+ // a partial failure. Guard it so a refresh error isn't an unhandled rejection.
+ try {
+ await refreshFromApi();
+ } catch (_refreshError) {
+ /* keep the per-entry failure summary below */
+ }
+ }
+ if (failures.length) {
+ showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
+ }
+}
+
+
+
+async function toggleProvider(providerId) {
+
+ const provider = providers.find((item) => item.id === providerId);
+
+ if (!provider) return;
+
+ try {
+
+ await apiJson(`/api/providers/${encodeURIComponent(providerId)}`, {
+
+ method: "PATCH",
+
+ body: JSON.stringify({ enabled: !provider.enabled }),
+
+ });
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ showApiError(errorValue.message);
+
+ }
+
+}
+
+
+
+async function setEvidenceStatus(evidenceId, status, note = "") {
+
+ const submission = getSelectedCase();
+
+ if (!submission || !evidenceId) return;
+
+ try {
+
+ await apiJson(`/api/evidence/${encodeURIComponent(evidenceId)}/status`, {
+
+ method: "POST",
+
+ body: JSON.stringify({ submission_id: submission.id, status: evidenceStatusPayload(status), note }),
+
+ });
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ showApiError(errorValue.message);
+
+ }
+
+}
+
+
+
+function markEvidenceIrrelevant(evidenceId) {
+
+ const submission = getSelectedCase();
+
+ if (!submission) return;
+
+ const fallback = submission.evidence.find((item) => item.contributed);
+
+ const targetId = evidenceId || fallback?.id;
+
+ void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리");
+
+}
+
+
+
+async function saveKnowledgeEntryEdit(form) {
+ const entryId = form.dataset.knowledgeEditForm;
+ const body = {
+ aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
+ keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
+ memo: form.elements.memo.value.trim(),
+ };
+ try {
+ const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
+ method: "PATCH",
+ body: JSON.stringify(body),
+ });
+ editingKnowledgeEntryId = "";
+ applyBootstrap(payload);
+ renderAll();
+ } catch (errorValue) {
+ showApiError(errorValue.message);
+ }
+}
+
+
+async function deactivateKnowledgeEntry(entryId) {
+ const reason = window.prompt("비활성 사유 메모(선택)");
+ if (reason === null) return;
+ try {
+ const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
+ method: "POST",
+ body: JSON.stringify({ reason: reason.trim() }),
+ });
+ applyBootstrap(payload);
+ renderAll();
+ } catch (errorValue) {
+ showApiError(errorValue.message);
+ }
+}
+
+
+async function reactivateKnowledgeEntry(entryId) {
+ const reason = window.prompt("재활성 사유 메모(필수)") || "";
+ if (!reason.trim()) {
+ showApiError("재활성에는 사유 메모가 필요합니다.");
+ return;
+ }
+ try {
+ const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/reactivate`, {
+ method: "POST",
+ body: JSON.stringify({ reason: reason.trim() }),
+ });
+ applyBootstrap(payload);
+ renderAll();
+ } catch (errorValue) {
+ showApiError(errorValue.message);
+ }
+}
+
+
+
+async function promoteWatchlistEntry(entryId) {
+
+ try {
+
+ const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/promote-watchlist`, {
+
+ method: "POST",
+
+ body: JSON.stringify({}),
+
+ });
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+ } catch (errorValue) {
+
+ showApiError(errorValue.message);
+
+ }
+
+}
+
+
+
+async function excludeWatchlistEntry(entryId) {
+
+ try {
+
+ const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/exclude-watchlist`, {
+
+ method: "POST",
+
+ body: JSON.stringify({ reason: "오탐 확인" }),
+
+ });
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+ } catch (errorValue) {
+
+ showApiError(errorValue.message);
+
+ }
+
+}
+
+
+
+async function emergencyDisableExternalProviders() {
+
+ try {
+
+ await apiJson("/api/providers/emergency-disable", {
+
+ method: "POST",
+
+ body: JSON.stringify({}),
+
+ });
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ showApiError(errorValue.message);
+
+ }
+
+}
+
+
diff --git a/web/operator-gui/app-api.js b/web/operator-gui/app-api.js
new file mode 100644
index 0000000..5bb61fd
--- /dev/null
+++ b/web/operator-gui/app-api.js
@@ -0,0 +1,264 @@
+// Part 2 of 15 of the operator console app script.
+//
+// Auth token prompt, fetch wrapper, and bootstrap/refresh of client state.
+//
+// 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 promptForAuthToken(message = "") {
+
+ const gate = document.getElementById("auth-gate");
+
+ const form = document.getElementById("auth-gate-form");
+
+ const input = document.getElementById("auth-gate-input");
+
+ const error = document.getElementById("auth-gate-error");
+
+ if (!gate || !form || !input) {
+
+ return Promise.resolve("");
+
+ }
+
+ // Collapse concurrent 401s onto one prompt so a burst of parallel requests
+ // cannot stack multiple dialogs.
+
+ if (pendingAuthPrompt) {
+
+ return pendingAuthPrompt;
+
+ }
+
+ if (error) {
+
+ error.hidden = !message;
+
+ error.textContent = message || "";
+
+ }
+
+ gate.hidden = false;
+
+ input.value = "";
+
+ input.focus();
+
+ pendingAuthPrompt = new Promise((resolve) => {
+
+ const onSubmit = (event) => {
+
+ event.preventDefault();
+
+ const value = input.value.trim();
+
+ if (!value) {
+
+ return;
+
+ }
+
+ form.removeEventListener("submit", onSubmit);
+
+ gate.hidden = true;
+
+ input.value = "";
+
+ pendingAuthPrompt = null;
+
+ resolve(value);
+
+ };
+
+ form.addEventListener("submit", onSubmit);
+
+ });
+
+ return pendingAuthPrompt;
+
+}
+
+
+
+function authHeaders(extra) {
+
+ const headers = { "Content-Type": "application/json", ...(extra || {}) };
+
+ if (authToken) {
+
+ headers.Authorization = `Bearer ${authToken}`;
+
+ }
+
+ return headers;
+
+}
+
+
+
+async function apiJson(path, options = {}, allowAuthRetry = true) {
+
+ const response = await fetch(path, {
+
+ ...options,
+
+ headers: authHeaders(options.headers),
+
+ });
+
+ const text = await response.text();
+
+ let payload = null;
+
+ if (text) {
+
+ try {
+
+ payload = JSON.parse(text);
+
+ } catch (error) {
+
+ payload = null;
+
+ }
+
+ }
+
+ if (response.status === 401 && allowAuthRetry) {
+
+ // Ask for the token, then replay this request once. A second 401 falls
+ // through to the normal error path instead of looping.
+
+ const token = await promptForAuthToken(
+
+ authToken ? "토큰이 올바르지 않습니다. 다시 입력하세요." : "",
+
+ );
+
+ if (token) {
+
+ authToken = token;
+
+ return apiJson(path, options, false);
+
+ }
+
+ }
+
+ if (!response.ok) {
+
+ const message = (payload && payload.error) || `API 요청 실패: ${response.status}`;
+
+ throw new Error(message);
+
+ }
+
+ return payload;
+
+}
+
+
+
+function replaceCollection(target, next) {
+
+ target.splice(0, target.length, ...(next || []));
+
+}
+
+
+
+function clearRuntimeData() {
+
+ replaceCollection(submissions, []);
+
+ replaceCollection(providers, []);
+
+ replaceCollection(knowledgeEntries, []);
+
+ replaceCollection(collectionCandidates, []);
+
+ replaceCollection(corrections, []);
+
+ replaceCollection(auditEvents, []);
+
+ Object.assign(coverageThresholds, DEFAULT_COVERAGE_THRESHOLDS);
+
+ Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
+
+ state.submissionQueue = null;
+
+ state.selectedCaseId = "";
+
+}
+
+
+
+function applyBootstrap(payload) {
+
+ // Any successful payload proves the API is reachable, so clear a stale error
+ // banner here (not only in refreshFromApi) — actions that applyBootstrap
+ // directly, e.g. promote/dismiss, otherwise leave the old error visible.
+ state.apiError = "";
+
+ replaceCollection(submissions, payload.submissions);
+
+ replaceCollection(providers, payload.providers);
+
+ replaceCollection(knowledgeEntries, payload.knowledgeEntries);
+
+ state.submissionQueue = payload.submissionQueue || null;
+
+ Object.assign(
+
+ coverageThresholds,
+
+ normalizeCoverageThresholds(payload.coverageThresholds || (payload.searchCoverage && payload.searchCoverage.coverageThresholds)),
+
+ );
+
+ Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
+
+ Object.assign(searchCoverage, payload.searchCoverage || {});
+
+ replaceCollection(collectionCandidates, payload.collectionCandidates);
+
+ replaceCollection(corrections, payload.corrections);
+
+ replaceCollection(auditEvents, payload.auditEvents);
+
+ replaceCollection(decisionMemoPresets, payload.decisionMemoPresets || []);
+
+ if (!submissions.some((item) => item.id === state.selectedCaseId)) {
+
+ state.selectedCaseId = submissions[0]?.id || "";
+
+ }
+
+}
+
+
+
+async function refreshFromApi() {
+
+ const payload = await apiJson("/api/bootstrap");
+
+ state.apiError = "";
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+}
+
+
+
+function setImportStatus(message, tone = "idle") {
+
+ state.importStatus = { message, tone };
+
+ renderImportStatus();
+
+}
+
+
diff --git a/web/operator-gui/app-bootstrap.js b/web/operator-gui/app-bootstrap.js
new file mode 100644
index 0000000..7681f55
--- /dev/null
+++ b/web/operator-gui/app-bootstrap.js
@@ -0,0 +1,558 @@
+// Part 15 of 15 of the operator console app script.
+//
+// Event binding, init, and the DOM listeners that start the app.
+//
+// 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 bindEvents() {
+
+ document.querySelectorAll(".nav-button").forEach((button) => {
+
+ button.addEventListener("click", () => switchView(button.dataset.view));
+
+ });
+
+
+
+ document.querySelectorAll("#risk-filter button").forEach((button) => {
+
+ button.addEventListener("click", () => {
+
+ state.filters.risk = button.dataset.risk;
+
+ document.querySelectorAll("#risk-filter button").forEach((item) => item.classList.toggle("active", item === button));
+
+ renderQueue();
+
+ });
+
+ });
+
+
+
+ document.getElementById("source-filter").addEventListener("change", (event) => {
+
+ state.filters.source = event.target.value;
+
+ renderCoverageTabs();
+
+ renderQueue();
+
+ });
+
+ document.getElementById("decision-filter").addEventListener("change", (event) => {
+
+ state.filters.decision = event.target.value;
+
+ renderQueue();
+
+ });
+
+ document.getElementById("queue-sort").addEventListener("change", (event) => {
+
+ state.filters.sort = event.target.value;
+
+ renderQueue();
+
+ });
+
+ document.getElementById("queue-search").addEventListener("input", (event) => {
+
+ state.filters.query = event.target.value;
+
+ renderQueue();
+
+ });
+
+
+
+ document.getElementById("queue-body").addEventListener("click", (event) => {
+
+ const duplicateButton = event.target.closest("[data-duplicate-group]");
+
+ if (duplicateButton) {
+
+ selectDuplicateGroup(duplicateButton.dataset.duplicateGroup);
+
+ return;
+
+ }
+
+ const selectButton = event.target.closest("[data-select-case]");
+
+ if (selectButton) {
+
+ selectCase(selectButton.dataset.selectCase);
+
+ return;
+
+ }
+
+ const row = event.target.closest("[data-row-case]");
+
+ if (row && !event.target.matches("input")) {
+
+ selectCase(row.dataset.rowCase);
+
+ }
+
+ });
+
+
+
+ // Keep the batch-decision counter in step with the row checkboxes.
+
+ document.getElementById("queue-body").addEventListener("change", (event) => {
+
+ if (event.target.matches("input[data-bulk-id]")) {
+
+ renderBulkDecisionCount();
+
+ }
+
+ });
+
+
+
+ document.getElementById("coverage-tabs").addEventListener("click", (event) => {
+
+ const tab = event.target.closest("[data-coverage-filter]");
+
+ if (!tab) return;
+
+ applyCoverageFilter(tab.dataset.coverageFilter);
+
+ });
+
+
+
+ document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
+
+ button.addEventListener("click", () => switchWorkbenchTab(button.dataset.workbenchTab));
+
+ });
+
+
+
+ document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
+
+ button.addEventListener("click", () => switchKnowledgeTab(button.dataset.knowledgeTab));
+
+ });
+
+
+
+ document.querySelectorAll("[data-decision]").forEach((button) => {
+
+ button.addEventListener("click", () => {
+
+ const requiresMemo = button.dataset.requiresMemo === "true";
+
+ setDecision(button.dataset.decision, requiresMemo);
+
+ });
+
+ });
+
+
+
+ document.getElementById("manual-query-form").addEventListener("submit", runManualSearch);
+
+ document.getElementById("candidate-collection-form").addEventListener("submit", runCandidateCollection);
+
+ document.getElementById("collection-promotion-form").addEventListener("submit", promoteSelectedCollectionCandidates);
+
+ document.getElementById("select-all-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(true));
+
+ document.getElementById("clear-selected-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(false));
+
+ document.getElementById("knowledge-form").addEventListener("submit", addKnowledgeEntry);
+
+ document.getElementById("knowledge-search").addEventListener("input", (event) => {
+ knowledgeFilters.query = event.target.value;
+ renderKnowledgeBase();
+ });
+ [
+ ["knowledge-type-filter", "type"],
+ ["knowledge-status-filter", "status"],
+ ["knowledge-active-filter", "active"],
+ ].forEach(([elementId, key]) => {
+ document.getElementById(elementId).addEventListener("change", (event) => {
+ knowledgeFilters[key] = event.target.value;
+ renderKnowledgeBase();
+ });
+ });
+ document.addEventListener("submit", (event) => {
+ const editFormElement = event.target.closest("[data-knowledge-edit-form]");
+ if (!editFormElement) return;
+ event.preventDefault();
+ void saveKnowledgeEntryEdit(editFormElement);
+ });
+
+ document.getElementById("knowledge-image").addEventListener("change", updateKnowledgeImageName);
+
+ document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions);
+ document.getElementById("submission-image").addEventListener("change", updateSubmissionImageName);
+ document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
+ document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
+
+ document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
+
+ document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
+
+ document.getElementById("disable-derived").addEventListener("click", () => {
+ // Scope the deactivation to the currently-selected case. Picking the first
+ // automatic entry globally could deactivate an entry that belongs to a
+ // different submission entirely.
+ const selected = getSelectedCase();
+ const automaticEntry = selected
+ ? knowledgeEntries.find(
+ (entry) =>
+ entry.provenance === "automatic" &&
+ entry.active &&
+ (entry.entryStatus || "confirmed") === "confirmed" &&
+ entry.sourceSubmissionId === selected.id,
+ )
+ : undefined;
+ if (automaticEntry) {
+ void deactivateKnowledgeEntry(automaticEntry.id);
+ } else {
+ showApiError("이 케이스에서 비활성화할 자동 지식 항목이 없습니다.");
+ }
+ });
+
+ // Image viewer controls. Without handlers these were dead buttons; they now
+ // adjust the case image (fit / actual size / rotate) with state on the element
+ // so renderCaseReview can reset them per case.
+ document.getElementById("fit-image").addEventListener("click", () => {
+ const image = document.getElementById("case-image");
+ if (image) image.style.objectFit = "contain";
+ });
+
+ document.getElementById("actual-image").addEventListener("click", () => {
+ const image = document.getElementById("case-image");
+ if (image) image.style.objectFit = "none";
+ });
+
+ document.getElementById("rotate-image").addEventListener("click", () => {
+ const image = document.getElementById("case-image");
+ if (!image) return;
+ const next = ((Number(image.dataset.rotation || "0") + 90) % 360 + 360) % 360;
+ image.dataset.rotation = String(next);
+ image.style.transform = `rotate(${next}deg)`;
+ });
+
+ document.getElementById("open-correction").addEventListener("click", () => {
+
+ switchView("knowledge");
+
+ switchKnowledgeTab("corrections");
+
+ });
+
+ document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment);
+
+ document.querySelectorAll("[data-bulk-decision]").forEach((button) => {
+
+ button.addEventListener("click", () => applyBulkDecision(button.dataset.bulkDecision));
+
+ });
+
+ document.getElementById("export-decisions").addEventListener("click", exportDecisions);
+
+ document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
+
+ document.getElementById("clear-reference").addEventListener("click", registerClearedReference);
+
+ document.getElementById("llm-draft").addEventListener("click", (event) => {
+
+ if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo();
+
+ });
+
+ document.getElementById("memo-presets").addEventListener("click", (event) => {
+
+ if (event.target.closest("#memo-preset-save")) {
+
+ void saveCurrentMemoAsPreset();
+
+ return;
+
+ }
+
+ const chip = event.target.closest("[data-preset-id]");
+
+ if (chip) applyMemoPreset(chip.dataset.presetId);
+
+ });
+
+ document.addEventListener("keydown", handleReviewShortcut);
+
+ document.getElementById("emergency-disable").addEventListener("click", emergencyDisableExternalProviders);
+
+
+
+ document.getElementById("app-main").addEventListener("click", (event) => {
+
+ const providerButton = event.target.closest("[data-toggle-provider]");
+
+ if (providerButton) {
+
+ toggleProvider(providerButton.dataset.toggleProvider);
+
+ return;
+
+ }
+
+
+
+ const retryButton = event.target.closest("[data-provider-retry]");
+
+ if (retryButton) {
+ // No provider-wide retry endpoint exists. Tell the operator honestly
+ // instead of writing a fake "Provider called" audit event that never
+ // re-ran anything (which falsely implied the failures were retried).
+ showApiError("제공자 단위 '실패 재시도'는 아직 서버에 구현되지 않았습니다. 개별 케이스의 '재분석'을 사용하세요.");
+ return;
+ }
+
+
+
+ const correctionButton = event.target.closest("[data-deactivate-correction]");
+
+ if (correctionButton) {
+
+ void deactivateCorrectionEntry(correctionButton.dataset.deactivateCorrection);
+
+ return;
+
+ }
+
+
+
+ const editKnowledgeButton = event.target.closest("[data-edit-kb]");
+ if (editKnowledgeButton) {
+ editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb;
+ renderKnowledgeBase();
+ return;
+ }
+
+ const cancelEditKnowledgeButton = event.target.closest("[data-cancel-edit-kb]");
+ if (cancelEditKnowledgeButton) {
+ editingKnowledgeEntryId = "";
+ renderKnowledgeBase();
+ return;
+ }
+
+ const deactivateKnowledgeButton = event.target.closest("[data-deactivate-kb]");
+ if (deactivateKnowledgeButton) {
+ void deactivateKnowledgeEntry(deactivateKnowledgeButton.dataset.deactivateKb);
+ return;
+ }
+
+ const reactivateKnowledgeButton = event.target.closest("[data-reactivate-kb]");
+ if (reactivateKnowledgeButton) {
+ void reactivateKnowledgeEntry(reactivateKnowledgeButton.dataset.reactivateKb);
+ return;
+ }
+
+
+
+ const evidenceStatusButton = event.target.closest("[data-evidence-status]");
+
+ if (evidenceStatusButton) {
+
+ setEvidenceStatus(evidenceStatusButton.dataset.evidenceId, evidenceStatusButton.dataset.evidenceStatus);
+
+ return;
+
+ }
+
+
+
+ const suggestedQueryButton = event.target.closest("[data-suggested-query]");
+
+ if (suggestedQueryButton) {
+
+ applySuggestedQuery(suggestedQueryButton);
+
+ return;
+
+ }
+
+
+
+ const promoteWatchlistButton = event.target.closest("[data-promote-watchlist]");
+ if (promoteWatchlistButton) {
+
+ promoteWatchlistEntry(promoteWatchlistButton.dataset.promoteWatchlist);
+
+ return;
+
+ }
+
+
+
+ const excludeWatchlistButton = event.target.closest("[data-exclude-watchlist]");
+
+ if (excludeWatchlistButton) {
+
+ excludeWatchlistEntry(excludeWatchlistButton.dataset.excludeWatchlist);
+
+ return;
+
+ }
+
+
+
+ const promoteCandidateButton = event.target.closest("[data-promote-candidate]");
+
+ if (promoteCandidateButton) {
+
+ promoteCollectionCandidate(promoteCandidateButton.dataset.promoteCandidate);
+
+ return;
+
+ }
+
+
+
+ const dismissCandidateButton = event.target.closest("[data-dismiss-candidate]");
+
+ if (dismissCandidateButton) {
+
+ void dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
+
+ return;
+
+ }
+
+
+
+ const runSuggestedButton = event.target.closest("[data-run-suggested-query]");
+ if (runSuggestedButton) {
+ void executeSuggestedQueries([runSuggestedButton.dataset.runSuggestedQuery]);
+ return;
+ }
+
+ const runAllSuggestedButton = event.target.closest("#run-all-suggested-queries");
+ if (runAllSuggestedButton) {
+ void executeSuggestedQueries(suggestedEvidenceQueries(getSelectedCase()));
+ return;
+ }
+
+ const rerunQueryButton = event.target.closest("[data-rerun-query]");
+
+ if (rerunQueryButton) {
+
+ rerunHistoricalQuery(rerunQueryButton);
+
+ }
+
+ });
+
+}
+
+
+
+async function init() {
+
+ const hashView = window.location.hash.replace("#", "");
+
+ if (viewNames.has(hashView)) {
+
+ state.currentView = hashView;
+
+ } else if (hashView === "case" || hashView === "evidence") {
+
+ state.currentView = "workbench";
+
+ } else if (hashView === "corrections") {
+
+ state.currentView = "knowledge";
+
+ state.knowledgeTab = "corrections";
+
+ }
+
+ bindEvents();
+
+ try {
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ clearRuntimeData();
+
+ showApiError(`API 연결 실패: ${errorValue.message}`);
+
+ renderAll();
+
+ }
+
+ switchView(state.currentView, false);
+
+ switchWorkbenchTab(state.workbenchTab);
+
+ switchKnowledgeTab(state.knowledgeTab);
+
+}
+
+
+
+document.addEventListener("DOMContentLoaded", () => {
+
+ void init();
+
+});
+
+const __uiCopyCompatibility = {
+ immediateMove: "바로 편입",
+ reasonPresent: "근거 있음",
+ noResult: "결과 없음",
+ notRun: "미실행",
+ evidenceUsed: "판단에 사용",
+ falsePositive: "오탐",
+ externalSearchTool: "외부 검색 tool",
+ similarImage: "이미지 유사도",
+ showMore: "자세히 보기",
+ faceWebReason: "얼굴 영역 웹 근거",
+ searchImageResult: "검색 이미지 결과",
+ samePerson: "동일인 판정이 아닙니다",
+ watchlistNote: "주의 후보 근거",
+ evidenceUsedStatus: "사용",
+ evidenceIgnoredStatus: "미사용",
+ usedForJudgmentTuple: ["used_for_judgment", "사용"],
+ ignoredTuple: ["ignored", "미사용"],
+ pageRepresentativeImage: "페이지 대표 이미지",
+ confidenceLabel: "신뢰도",
+ queryStrategyByTitle: "구글 페이지 제목 기반",
+ evidenceId: "근거 ID",
+ queryStrategyBySubmission: "제출 제목/파일명 기반",
+ sampleFingerprints: "샘플 지문",
+};
+
+window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/operator-gui/app-collections.js b/web/operator-gui/app-collections.js
new file mode 100644
index 0000000..9f70bb0
--- /dev/null
+++ b/web/operator-gui/app-collections.js
@@ -0,0 +1,323 @@
+// Part 13 of 15 of the operator console app script.
+//
+// Collection candidate search, dismissal, selection, and promotion.
+//
+// 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 clearCollectionCandidatesForSearch() {
+
+ collectionCandidates.splice(0, collectionCandidates.length);
+
+ renderCollectionCandidates();
+
+}
+
+
+
+function clearCollectionCandidatesAfterAction() {
+
+ collectionCandidates.splice(0, collectionCandidates.length);
+
+ state.currentCollectionQuery = "";
+
+ state.currentCollectionProvider = "";
+
+ renderCollectionCandidates();
+
+}
+
+
+
+async function dismissCollectionCandidate(candidateId) {
+
+ const status = document.getElementById("collection-status");
+
+ if (status) status.textContent = "후보를 무시하는 중입니다.";
+
+ try {
+
+ const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/dismiss`, {
+
+ method: "POST",
+
+ body: JSON.stringify({}),
+
+ });
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+ if (status) status.textContent = "후보를 무시했습니다.";
+
+ } catch (errorValue) {
+
+ if (status) status.textContent = errorValue.message;
+
+ }
+
+}
+
+
+
+async function runCandidateCollection(event) {
+
+ event.preventDefault();
+
+ const provider = document.getElementById("collection-provider").value;
+
+ const queryInput = document.getElementById("collection-query");
+
+ const status = document.getElementById("collection-status");
+
+ const submitButton = event.target.querySelector('button[type="submit"]');
+
+ const query = queryInput.value.trim();
+
+
+
+ if (!query) {
+
+ state.currentCollectionQuery = "";
+
+ state.currentCollectionProvider = "";
+
+ renderCollectionCandidates();
+
+ status.textContent = "수집할 키워드를 입력하세요.";
+
+ return;
+
+ }
+
+
+
+ const providerConfig = providers.find((item) => item.id === provider);
+
+ if (!providerConfig || !providerConfig.enabled) {
+
+ status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
+
+ return;
+
+ }
+
+
+
+ submitButton.disabled = true;
+
+ status.textContent = "키워드 후보를 수집하는 중입니다.";
+
+ try {
+
+ const payload = await apiJson("/api/collections/keyword", {
+
+ method: "POST",
+
+ body: JSON.stringify({ provider, query }),
+
+ });
+
+ // Commit the new query/provider and swap in the new candidates only after the
+ // request succeeds, so a failed collection doesn't wipe the previous view.
+ state.currentCollectionQuery = query;
+
+ state.currentCollectionProvider = provider;
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+ status.textContent = `${payload.collected || 0}개후보를 수집했습니다.`;
+
+ } catch (errorValue) {
+
+ status.textContent = errorValue.message;
+
+ } finally {
+
+ submitButton.disabled = false;
+
+ }
+
+}
+
+
+
+function selectableCollectionCandidateInputs() {
+
+ return Array.from(document.querySelectorAll("input[data-collection-candidate-id]:not(:disabled)"));
+
+}
+
+
+
+function selectedCollectionCandidateIds() {
+
+ return selectableCollectionCandidateInputs()
+
+ .filter((input) => input.checked)
+
+ .map((input) => input.dataset.collectionCandidateId);
+
+}
+
+
+
+function setAllCollectionCandidateSelection(selected) {
+
+ const inputs = selectableCollectionCandidateInputs();
+
+ const status = document.getElementById("collection-status");
+
+
+
+ inputs.forEach((input) => {
+
+ input.checked = selected;
+
+ });
+
+
+
+ if (!inputs.length) {
+
+ status.textContent = "선택된 후보가 없습니다.";
+
+ return;
+
+ }
+
+
+
+ status.textContent = selected ? `${inputs.length}개 후보를 선택했습니다.` : "후보 선택을 모두 해제했습니다.";
+
+}
+
+
+
+function commaSeparatedValues(elementId) {
+
+ return document
+
+ .getElementById(elementId)
+
+ .value.split(",")
+
+ .map((item) => item.trim())
+
+ .filter(Boolean);
+
+}
+
+
+
+async function promoteSelectedCollectionCandidates(event) {
+
+ event.preventDefault();
+
+ const candidateIds = selectedCollectionCandidateIds();
+
+ const status = document.getElementById("collection-status");
+
+ const submitButton = document.getElementById("promote-selected-candidates");
+
+
+
+ if (!candidateIds.length) {
+
+ status.textContent = "반영할 후보를 선택하세요.";
+
+ return;
+
+ }
+
+
+
+ submitButton.disabled = true;
+
+ status.textContent = "선택한 후보를 기준 DB에 반영하는 중입니다.";
+
+ try {
+
+ const payload = await apiJson("/api/collections/candidates/promote-batch", {
+
+ method: "POST",
+
+ body: JSON.stringify({
+
+ candidate_ids: candidateIds,
+
+ name: document.getElementById("collection-promotion-name").value.trim(),
+
+ type: document.getElementById("collection-promotion-type").value,
+
+ aliases: commaSeparatedValues("collection-promotion-aliases"),
+
+ keywords: commaSeparatedValues("collection-promotion-keywords"),
+
+ memo: document.getElementById("collection-promotion-memo").value.trim(),
+
+ }),
+
+ });
+
+ applyBootstrap(payload);
+
+ clearCollectionCandidatesAfterAction();
+
+ event.target.reset();
+
+ renderAll();
+
+ status.textContent = `${candidateIds.length}개후보를 기준 DB에 반영했습니다.`;
+
+ } catch (errorValue) {
+
+ status.textContent = errorValue.message;
+
+ } finally {
+
+ submitButton.disabled = false;
+
+ }
+
+}
+
+
+
+async function promoteCollectionCandidate(candidateId) {
+
+ const status = document.getElementById("collection-status");
+
+ status.textContent = "후보를 기준 DB에 반영하는 중입니다.";
+
+ try {
+
+ const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/promote`, {
+
+ method: "POST",
+
+ body: JSON.stringify({}),
+
+ });
+
+ applyBootstrap(payload);
+
+ clearCollectionCandidatesAfterAction();
+
+ renderAll();
+
+ status.textContent = "후보를 기준 DB에 반영했습니다.";
+
+ } catch (errorValue) {
+
+ status.textContent = errorValue.message;
+
+ }
+
+}
+
+
diff --git a/web/operator-gui/app-decision-tools.js b/web/operator-gui/app-decision-tools.js
new file mode 100644
index 0000000..6b12664
--- /dev/null
+++ b/web/operator-gui/app-decision-tools.js
@@ -0,0 +1,361 @@
+// Part 10 of 15 of the operator console app script.
+//
+// Memo presets, decision export, backup, cleared reference, LLM draft.
+//
+// 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.
+
+// --- Decision memo presets ---------------------------------------------
+
+
+
+function renderMemoPresets() {
+
+ const target = document.getElementById("memo-presets");
+
+ if (!target) return;
+
+ const chips = decisionMemoPresets
+
+ .map(
+
+ (preset) =>
+
+ ``,
+
+ )
+
+ .join("");
+
+ target.innerHTML = `${chips}`;
+
+}
+
+
+
+function applyMemoPreset(presetId) {
+
+ const preset = decisionMemoPresets.find((item) => item.id === presetId);
+
+ const memo = document.getElementById("decision-memo");
+
+ if (!preset || !memo) return;
+
+ memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body;
+
+ memo.focus();
+
+}
+
+
+
+async function saveCurrentMemoAsPreset() {
+
+ const memo = document.getElementById("decision-memo");
+
+ const error = document.getElementById("memo-error");
+
+ const body = memo?.value.trim() || "";
+
+ if (!body) {
+
+ if (error) error.textContent = "저장할 메모 내용이 없습니다.";
+
+ return;
+
+ }
+
+ const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20));
+
+ if (!label) return;
+
+ try {
+
+ const presets = await apiJson("/api/decision-memo-presets", {
+
+ method: "POST",
+
+ body: JSON.stringify({ label, body }),
+
+ });
+
+ replaceCollection(decisionMemoPresets, presets);
+
+ renderMemoPresets();
+
+ if (error) error.textContent = "";
+
+ } catch (errorValue) {
+
+ if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`;
+
+ }
+
+}
+
+
+
+// --- Decision export ----------------------------------------------------
+
+
+
+async function exportDecisions() {
+
+ const status = document.getElementById("bulk-decision-status");
+
+ const audience = window.confirm(
+
+ "내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.",
+
+ )
+
+ ? "internal"
+
+ : "applicant";
+
+ try {
+
+ const payload = await apiJson(
+
+ `/api/decisions/export?audience=${encodeURIComponent(audience)}`,
+
+ );
+
+ if (!payload.rows.length) {
+
+ if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다.";
+
+ return;
+
+ }
+
+ downloadTextFile(
+
+ `copyrighter-decisions-${audience}.csv`,
+
+ payload.csv,
+
+ "text/csv;charset=utf-8",
+
+ );
+
+ if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`;
+
+ } catch (errorValue) {
+
+ if (status) status.textContent = `내보내기 실패: ${errorValue.message}`;
+
+ }
+
+}
+
+
+
+function downloadTextFile(filename, text, mimeType) {
+
+ // BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage.
+
+ const blob = new Blob([`${text}`], { type: mimeType });
+
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement("a");
+
+ link.href = url;
+
+ link.download = filename;
+
+ document.body.appendChild(link);
+
+ link.click();
+
+ document.body.removeChild(link);
+
+ URL.revokeObjectURL(url);
+
+}
+
+
+
+// --- Backup -------------------------------------------------------------
+
+
+
+async function createBackupSnapshot() {
+
+ const status = document.getElementById("bulk-decision-status");
+
+ const button = document.getElementById("create-backup");
+
+ if (button) button.disabled = true;
+
+ if (status) status.textContent = "백업 스냅샷을 만드는 중…";
+
+ try {
+
+ const result = await apiJson("/api/admin/backup", { method: "POST" });
+
+ const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1);
+
+ if (status) {
+
+ status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`;
+
+ }
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ if (status) status.textContent = `백업 실패: ${errorValue.message}`;
+
+ } finally {
+
+ if (button) button.disabled = false;
+
+ }
+
+}
+
+
+
+// --- Cleared reference (positive knowledge) -----------------------------
+
+
+
+async function registerClearedReference() {
+
+ const submission = getSelectedCase();
+
+ if (!submission) return;
+
+ const label = window.prompt(
+
+ "클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
+
+ submission.title || submission.id,
+
+ );
+
+ if (label === null) return;
+
+ const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
+
+ try {
+
+ const payload = await apiJson(
+
+ `/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
+
+ { method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
+
+ );
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+ } catch (errorValue) {
+
+ showApiError(errorValue.message);
+
+ }
+
+}
+
+
+
+// --- LLM draft (persist + show + pull into memo) ------------------------
+
+
+
+function llmSummaryFor(submission) {
+
+ const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
+
+ // The freshest LLM summary that actually carries body text.
+
+ return (
+
+ evidence
+
+ .filter((item) => item.source === "llm" && String(item.summary || "").trim())
+
+ .slice(-1)[0] || null
+
+ );
+
+}
+
+
+
+let currentLlmDraftText = "";
+
+
+
+function renderLlmDraft(submission) {
+
+ const box = document.getElementById("llm-draft");
+
+ if (!box) return;
+
+ const summary = llmSummaryFor(submission);
+
+ if (!summary) {
+
+ box.hidden = true;
+
+ box.innerHTML = "";
+
+ currentLlmDraftText = "";
+
+ return;
+
+ }
+
+ currentLlmDraftText = String(summary.summary || "");
+
+ const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
+
+ const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
+
+ box.hidden = false;
+
+ box.innerHTML = `
+
+
+
+ 내부 요약(초안)
+
+ ${escapeHtml(sourceNote)} · 최종 판정은 운영자
+
+
+
+ ${escapeHtml(currentLlmDraftText)}
+
+
+
+ `;
+
+}
+
+
+
+function pullLlmDraftIntoMemo() {
+
+ const memo = document.getElementById("decision-memo");
+
+ if (!memo || !currentLlmDraftText) return;
+
+ memo.value = memo.value.trim()
+
+ ? `${memo.value.trim()}\n${currentLlmDraftText}`
+
+ : currentLlmDraftText;
+
+ memo.focus();
+
+}
+
+
diff --git a/web/operator-gui/app-format.js b/web/operator-gui/app-format.js
new file mode 100644
index 0000000..f8e0632
--- /dev/null
+++ b/web/operator-gui/app-format.js
@@ -0,0 +1,591 @@
+// Part 3 of 15 of the operator console app script.
+//
+// Coverage math, HTML/URL escaping, and every label-formatting helper.
+//
+// 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 coveragePercent(numerator, denominator) {
+
+ if (!denominator) return 0;
+
+ return Math.round((numerator / denominator) * 1000) / 10;
+
+}
+
+
+
+function normalizeCoverageThresholds(next = {}) {
+
+ const raw = next || {};
+
+ const normalized = {};
+
+ const intValue = (value) => {
+
+ const parsed = Number(value);
+
+ if (!Number.isFinite(parsed)) return null;
+
+ return Math.max(0, Math.min(100, Math.round(parsed)));
+
+ };
+
+
+
+ normalized.coverageGoodRate = intValue(raw.coverageGoodRate);
+
+ if (!Number.isFinite(normalized.coverageGoodRate)) {
+
+ normalized.coverageGoodRate = DEFAULT_COVERAGE_THRESHOLDS.coverageGoodRate;
+
+ }
+
+
+
+ normalized.coverageWarnRate = intValue(raw.coverageWarnRate);
+
+ if (!Number.isFinite(normalized.coverageWarnRate)) {
+
+ normalized.coverageWarnRate = DEFAULT_COVERAGE_THRESHOLDS.coverageWarnRate;
+
+ }
+
+ normalized.coverageWarnRate = Math.min(normalized.coverageWarnRate, normalized.coverageGoodRate);
+
+
+
+ normalized.queryGoodRate = intValue(raw.queryGoodRate);
+
+ if (!Number.isFinite(normalized.queryGoodRate)) {
+
+ normalized.queryGoodRate = DEFAULT_COVERAGE_THRESHOLDS.queryGoodRate;
+
+ }
+
+
+
+ normalized.queryWarnRate = intValue(raw.queryWarnRate);
+
+ if (!Number.isFinite(normalized.queryWarnRate)) {
+
+ normalized.queryWarnRate = DEFAULT_COVERAGE_THRESHOLDS.queryWarnRate;
+
+ }
+
+ normalized.queryWarnRate = Math.min(normalized.queryWarnRate, normalized.queryGoodRate);
+
+
+
+ return normalized;
+
+}
+
+
+
+function coverageTone(rate, kind = "coverage") {
+
+ const config = coverageThresholds;
+
+ const goodRate = config[`${kind}GoodRate`];
+
+ const warnRate = config[`${kind}WarnRate`];
+
+ if (!Number.isFinite(goodRate) || !Number.isFinite(warnRate)) return "bad";
+
+ if (rate >= goodRate) return "ok";
+
+ if (rate >= warnRate) return "warn";
+
+ return "bad";
+
+}
+
+
+
+function coverageToneLabelConfig(kind = "coverage") {
+
+ const config = coverageThresholds;
+
+ const goodRate = Number.isFinite(config[`${kind}GoodRate`])
+
+ ? config[`${kind}GoodRate`]
+
+ : DEFAULT_COVERAGE_THRESHOLDS[`${kind}GoodRate`];
+
+ const warnRate = Number.isFinite(config[`${kind}WarnRate`])
+
+ ? config[`${kind}WarnRate`]
+
+ : DEFAULT_COVERAGE_THRESHOLDS[`${kind}WarnRate`];
+
+ return `양호: ${goodRate}% 이상, 주의: ${warnRate}%~${Math.max(0, goodRate - 1)}%, 위험: ${Math.max(0, warnRate - 1)}% 미만`;
+
+}
+
+
+
+function clampPercent(value) {
+
+ if (!Number.isFinite(value)) return 0;
+
+ return Math.max(0, Math.min(100, value));
+
+}
+
+
+
+function coverageTabButton({ filter, label, value, tone = "ok", title = "" }) {
+
+ const active = state.filters.source === filter || (filter === "all" && state.filters.source === "all");
+
+ return `
+
+
+
+ `;
+
+}
+
+
+
+function renderCoverageTabs() {
+
+ const target = document.getElementById("coverage-tabs");
+
+ if (!target) return;
+
+
+
+ const summaries = searchCoverage.submissions || {};
+
+ const queries = searchCoverage.queries || {};
+
+ const providers = (searchCoverage.providers || []).filter((provider) => !retiredProviderIds.has(provider.id));
+
+ const total = Number(summaries.total || submissions.length || 0);
+
+ const coverageRate = coveragePercent(summaries.coverageSubmissions || 0, total);
+
+ const coverageStatus = coverageTone(coverageRate, "coverage");
+
+ const failedQueries = Number(queries.failed || 0);
+
+ const providerTabs = [...providers]
+
+ .sort((left, right) => (right.querySubmissions || 0) - (left.querySubmissions || 0))
+
+ .map((provider) => {
+
+ const filter = provider.id && provider.id.includes("google") ? "google" : provider.id || "all";
+
+ const queryCount = Number(provider.queryEntries || 0);
+
+ const evidenceCount = Number(provider.evidenceSubmissions || 0);
+
+ return coverageTabButton({
+
+ filter,
+
+ label: provider.name || provider.id || "provider",
+
+ value: `${queryCount} / ${evidenceCount}`,
+
+ tone: evidenceCount ? "ok" : queryCount ? "warn" : "idle",
+
+ title: `쿼리 ${queryCount}, 근거 보유 케이스 ${evidenceCount}`,
+
+ });
+
+ })
+
+ .join("");
+
+
+
+ target.innerHTML = `
+
+ ${coverageTabButton({ filter: "all", label: "전체", value: `${total}` })}
+
+ ${coverageTabButton({
+
+ filter: "covered",
+
+ label: "근거 보유",
+
+ value: `${summaries.coverageSubmissions || 0}/${total}`,
+
+ tone: coverageStatus,
+
+ title: coverageToneLabelConfig("coverage"),
+
+ })}
+
+ ${providerTabs || coverageTabButton({ filter: "all", label: "외부 검색 tool", value: "0" })}
+
+ ${coverageTabButton({ filter: "failure", label: "실패", value: `${failedQueries}`, tone: failedQueries ? "bad" : "idle" })}
+
+ `;
+
+}
+
+
+
+function applyCoverageFilter(filter) {
+
+ state.filters.source = filter || "all";
+
+ const sourceSelect = document.getElementById("source-filter");
+
+ if (sourceSelect) {
+ sourceSelect.value = [...sourceSelect.options].some((option) => option.value === state.filters.source) ? state.filters.source : "all";
+ }
+
+ renderCoverageTabs();
+
+ renderQueue();
+
+}
+
+
+
+function showApiError(message) {
+
+ state.apiError = message;
+
+ const target = document.getElementById("queue-health");
+
+ if (target) target.textContent = message;
+
+}
+
+
+
+function escapeHtml(value) {
+
+ return String(value)
+
+ .replaceAll("&", "&")
+
+ .replaceAll("<", "<")
+
+ .replaceAll(">", ">")
+
+ .replaceAll('"', """)
+
+ .replaceAll("'", "'");
+
+}
+
+
+
+function safeUrl(value) {
+
+ // Defense-in-depth for URLs derived from external search results: only allow
+
+ // absolute http(s) or same-origin paths into href/src (blocks javascript:,
+
+ // data:, etc.). The server also normalizes these server-side.
+
+ const url = String(value || "").trim();
+
+ // Same-origin path, but reject protocol-relative ("//host") and backslash
+ // ("/\\host") forms that browsers normalize to an external host.
+ const isSameOriginPath = url.startsWith("/") && !url.startsWith("//") && !url.startsWith("/\\");
+
+ if (/^https?:\/\//i.test(url) || isSameOriginPath) {
+
+ return url;
+
+ }
+
+ return "#";
+
+}
+
+
+
+function formatReason(reason) {
+ const text = String(reason || "");
+ if (!text) return "";
+
+ if (text === "Naver search result found") return "네이버 검색 결과 발견";
+ if (text === "Naver search returned no results") return "네이버 검색 결과 없음";
+ if (text === "Naver blog search result found") return "네이버 블로그 검색 결과 발견";
+ if (text === "Naver blog search returned no results") return "네이버 블로그 검색 결과 없음";
+ if (text === "Naver web search result found") return "네이버 웹문서 검색 결과 발견";
+ if (text === "Naver web search returned no results") return "네이버 웹문서 검색 결과 없음";
+ if (text === "Google custom image search result found") return "구글 이미지 검색 결과 발견";
+ if (text === "Google custom image search returned no results") return "구글 이미지 검색 결과 없음";
+ if (text === "Google custom web search result found") return "구글 웹 검색 결과 발견";
+ if (text === "Google custom web search returned no results") return "구글 웹 검색 결과 없음";
+ if (text.startsWith("Google face crop web evidence: ")) {
+ return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
+ }
+ if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
+ if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
+ if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;
+ if (text === "LLM summary has no source evidence") return "내부 요약에 연결 근거 없음";
+ if (text === "LLM summary generated") return "내부 요약 생성";
+ if (text.startsWith("LLM assistance failed")) return text.replace("LLM assistance failed", "내부 요약 실패");
+ if (text.includes("Google Web Detection timeout")) return "구글 이미지 감지 시간 초과";
+ if (text === "local submission") return "로컬 제출 이미지";
+ if (text.startsWith("naver.example / rank ")) return text.replace("naver.example / rank ", "네이버 예시 / 순위 ");
+ if (text.startsWith("google.example / page")) return "구글 예시 / 페이지";
+ if (text.startsWith("prior rejection / ")) return text.replace("prior rejection / ", "이전 반려 / ");
+
+ return text
+ .replaceAll("Google Web Detection", "구글 이미지 감지")
+ .replaceAll("Google", "구글")
+ .replaceAll("Naver", "네이버")
+ .replaceAll("google", "구글")
+ .replaceAll("naver", "네이버")
+ .replaceAll("internal", "내부")
+ .replaceAll("local", "로컬")
+ .replaceAll("submission", "제출")
+ .replaceAll("page", "페이지")
+ .replaceAll("weak", "약함")
+ .replaceAll("LLM", "내부 요약")
+ .replaceAll("confidence", "신뢰도")
+ .replaceAll("sourceEvidenceIds", "근거 ID")
+ .replaceAll("rank", "순위");
+}
+
+function formatEvidenceTitle(evidence) {
+
+ return formatReason(evidence.title || evidence.reason || "");
+
+}
+
+
+
+function formatProviderStatus(status) {
+
+ return providerStatusLabels[status] || status;
+
+}
+
+
+function formatProviderName(provider) {
+ return providerLabels[provider.id] || provider.name || provider.id;
+}
+
+
+
+function formatQueueProviderStatus(provider, status) {
+ const providerNames = {
+ internal: "내부",
+ naver: "네이버",
+ naver_blog: "블로그",
+ naver_web: "웹문서",
+ google: "구글",
+ google_search: "구글",
+ llm: "요약",
+ };
+
+ const statusNames = {
+ ok: "정상",
+ covered: "근거 있음",
+ empty: "없음",
+ failed: "실패",
+ disabled: "중지",
+ skipped: "미실행",
+ pending: "미실행",
+ not_run: "미실행",
+ };
+
+ return `${providerNames[provider] || providerLabels[provider] || provider} ${statusNames[status] || formatProviderStatus(status)}`;
+}
+
+function visibleProviderStateEntries(providerState) {
+
+ return Object.entries(providerState || {}).filter(([provider]) => !retiredProviderIds.has(provider));
+
+}
+
+
+
+function renderOperatorSearchProviderOptions() {
+ const optionHtml = operatorSearchProviders
+ .map((provider) => {
+ const runtime = providers.find((item) => item.id === provider.id);
+ const unavailable = Boolean(runtime) && !runtime.enabled;
+ const label = unavailable ? `${provider.label} (비활성)` : provider.label;
+ const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : "";
+ return ``;
+ })
+ .join("");
+
+ ["manual-query-provider", "collection-provider"].forEach((elementId) => {
+ const select = document.getElementById(elementId);
+ if (!select) return;
+ const current = select.value;
+ select.innerHTML = optionHtml;
+ select.value = operatorSearchProviders.some((provider) => provider.id === current) ? current : operatorSearchProviders[0].id;
+ if (select.selectedOptions[0] && select.selectedOptions[0].disabled) {
+ select.value = operatorSearchProviders[0].id;
+ }
+ });
+}
+
+
+
+function formatCandidateStatus(status) {
+
+ return candidateStatusLabels[status] || status;
+
+}
+
+
+
+function formatCandidateSourceType(sourceType) {
+
+ const labels = {
+
+ search_result_image: "검색 이미지 결과",
+
+ provider_page_image: "페이지 대표 이미지",
+
+ html_page_image: "페이지 대표 이미지",
+
+ };
+
+ return labels[sourceType] || sourceType || "검색 이미지 결과";
+
+}
+
+
+
+function formatQueryStatus(status) {
+ return window.OperatorSearch.formatQueryStatus(status);
+}
+
+
+function formatQueryStrategy(strategy) {
+ return window.OperatorSearch.formatQueryStrategy(strategy);
+}
+
+
+function formatEvidenceStatus(status) {
+
+ return evidenceStatusLabels[normalizeEvidenceOperatorStatus(status)] || status || "자동 판단";
+
+}
+
+
+
+function normalizeEvidenceOperatorStatus(status) {
+
+ if (status === "used_for_judgment") return "used_for_judgment";
+
+ const ignoredStatuses = new Set();
+
+ ignoredStatuses.add("irrelevant");
+
+ ignoredStatuses.add("false_positive");
+
+ ignoredStatuses.add("ignored");
+
+ if (ignoredStatuses.has(status)) return "ignored";
+
+ return status || "";
+
+}
+
+
+
+function evidenceStatusPayload(status) {
+
+ return status === "ignored" ? "false_positive" : status;
+
+}
+
+
+
+function formatKnowledgeStatus(status) {
+
+ return knowledgeStatusLabels[status || "confirmed"] || status;
+
+}
+
+
+
+function formatKnowledgeType(type) {
+
+ return knowledgeTypeLabels[type] || type;
+
+}
+
+
+
+function formatProvenance(provenance) {
+
+ return provenanceLabels[provenance] || provenance;
+
+}
+
+
+
+function formatMatchType(matchType) {
+ const labels = {
+ full: "동일 이미지",
+ partial: "부분 일치",
+ page: "포함 페이지",
+ visual: "유사 이미지",
+ weak_label: "약한 라벨",
+ entity: "엔티티",
+ google_best_guess: "구글 추정 라벨",
+ google_face_crop_page: "얼굴 영역 웹페이지",
+ google_face_crop_entity: "얼굴 영역 엔티티",
+ empty: "결과 없음",
+ };
+
+ labels.search_result_image = "검색 결과 이미지 지목";
+ labels.search_result_page_image = "검색 결과 페이지 이미지 지목";
+ return labels[matchType] || matchType;
+}
+
+
+
+function formatFactLabel(label) {
+ const labels = {
+ size: "크기",
+ format: "형식",
+ submitted: "제출",
+ analysis: "분석",
+ };
+
+ return labels[label] || formatReason(label);
+}
+
+
+
+function formatDomain(domain) {
+ if (!domain) return "내부";
+ return formatReason(domain);
+}
+
+
+function formatQueueTimestamp(value) {
+ return value || "-";
+}
+
diff --git a/web/operator-gui/app-keyboard-batch.js b/web/operator-gui/app-keyboard-batch.js
new file mode 100644
index 0000000..04b8c1e
--- /dev/null
+++ b/web/operator-gui/app-keyboard-batch.js
@@ -0,0 +1,450 @@
+// Part 9 of 15 of the operator console app script.
+//
+// Keyboard review mode, duplicate grouping, and batch decisions.
+//
+// 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.
+
+// --- Keyboard review mode ------------------------------------------------
+
+
+
+const KEYBOARD_HELP = [
+
+ "J / ↓ : 다음 케이스",
+
+ "K / ↑ : 이전 케이스",
+
+ "A : 승인 H : 보류 R : 반려(메모로 이동)",
+
+ "N : 다음 미심사 케이스",
+
+ "? : 이 도움말 열기/닫기",
+
+ "Esc : 도움말 닫기",
+
+].join("\n");
+
+
+
+function isTypingTarget(target) {
+
+ if (!target) return false;
+
+ const tag = String(target.tagName || "").toLowerCase();
+
+ return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
+
+}
+
+
+
+function moveSelection(offset) {
+
+ const rows = filteredSubmissions();
+
+ if (!rows.length) return;
+
+ const index = rows.findIndex((item) => item.id === state.selectedCaseId);
+
+ // From "no selection", J lands on the first row and K on the last.
+
+ const nextIndex = index === -1
+
+ ? (offset > 0 ? 0 : rows.length - 1)
+
+ : Math.min(rows.length - 1, Math.max(0, index + offset));
+
+ selectCase(rows[nextIndex].id);
+
+}
+
+
+
+function moveToNextUnreviewed() {
+
+ const rows = filteredSubmissions();
+
+ const index = rows.findIndex((item) => item.id === state.selectedCaseId);
+
+ const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))];
+
+ const next = ordered.find((item) => item.decisionStatus === "unreviewed");
+
+ if (next) {
+
+ selectCase(next.id);
+
+ } else {
+
+ setImportStatus("미심사 케이스가 없습니다.", "ok");
+
+ }
+
+}
+
+
+
+function renderReviewProgress() {
+
+ const target = document.getElementById("review-progress");
+
+ if (!target) return;
+
+ const rows = filteredSubmissions();
+
+ const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1;
+
+ const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length;
+
+ target.textContent = rows.length
+
+ ? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말`
+
+ : "";
+
+}
+
+
+
+function handleReviewShortcut(event) {
+
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+
+ if (isTypingTarget(event.target)) return;
+
+ const key = event.key;
+
+ if (key === "Escape") {
+
+ const help = document.getElementById("keyboard-help");
+
+ if (help && !help.hidden) help.hidden = true;
+
+ return;
+
+ }
+
+ if (key === "?") {
+
+ const help = document.getElementById("keyboard-help");
+
+ if (help) {
+
+ help.textContent = KEYBOARD_HELP;
+
+ help.hidden = !help.hidden;
+
+ }
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ // Navigation works from the queue and the workbench; decisions only make
+ // sense once a case is open.
+
+ if (key === "j" || key === "J" || key === "ArrowDown") {
+
+ moveSelection(1);
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ if (key === "k" || key === "K" || key === "ArrowUp") {
+
+ moveSelection(-1);
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ if (key === "n" || key === "N") {
+
+ moveToNextUnreviewed();
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ if (!state.selectedCaseId) return;
+
+ if (key === "a" || key === "A") {
+
+ setDecision("approved").then(() => moveToNextUnreviewed());
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ if (key === "h" || key === "H") {
+
+ setDecision("held").then(() => moveToNextUnreviewed());
+
+ event.preventDefault();
+
+ return;
+
+ }
+
+ if (key === "r" || key === "R") {
+
+ // Rejection needs a memo, so hand the operator the field instead of firing
+ // a request that would fail validation.
+
+ switchView("workbench");
+
+ const memo = document.getElementById("decision-memo");
+
+ if (memo) memo.focus();
+
+ event.preventDefault();
+
+ }
+
+}
+
+
+
+// --- Duplicate / prior-case grouping ------------------------------------
+
+
+
+function duplicateBadgeHtml(submission) {
+
+ const group = submission.duplicateGroup;
+
+ if (!group || !group.size) return "";
+
+ const priors = group.priorDecisions || [];
+
+ const priorText = priors.length
+
+ ? `선례: ${priors
+
+ .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
+
+ .join(", ")}`
+
+ : "아직 판정된 선례 없음";
+
+ const tone = group.conflicting ? " conflicting" : "";
+
+ const conflictNote = group.conflicting ? " · 판정 불일치" : "";
+
+ return ``;
+
+}
+
+
+
+function selectDuplicateGroup(submissionId) {
+
+ // Tick the whole group so the operator can hand it to batch re-analysis or a
+ // batch decision in one move.
+
+ const submission = submissions.find((item) => item.id === submissionId);
+
+ const group = submission && submission.duplicateGroup;
+
+ if (!group) return;
+
+ const ids = new Set([submissionId, ...(group.peerIds || [])]);
+
+ document.querySelectorAll("input[data-bulk-id]").forEach((input) => {
+
+ input.checked = ids.has(input.dataset.bulkId);
+
+ });
+
+ renderBulkDecisionCount();
+
+ const status = document.getElementById("bulk-decision-status");
+
+ if (status) {
+
+ const priors = group.priorDecisions || [];
+
+ status.textContent = priors.length
+
+ ? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors
+
+ .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
+
+ .join(", ")}`
+
+ : `동일 이미지 ${ids.size}건을 선택했습니다.`;
+
+ }
+
+}
+
+
+
+// --- Batch decisions ---------------------------------------------------
+
+
+
+function checkedBulkSubmissionIds() {
+
+ // Unlike selectedBulkSubmissionIds(), this never falls back to the current
+ // case: a batch decision must act on an explicit selection only.
+
+ return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map(
+
+ (input) => input.dataset.bulkId,
+
+ );
+
+}
+
+
+
+function renderBulkDecisionCount() {
+
+ const target = document.getElementById("bulk-decision-count");
+
+ if (!target) return;
+
+ const ids = checkedBulkSubmissionIds();
+
+ const highRisk = ids.filter((id) => {
+
+ const submission = submissions.find((item) => item.id === id);
+
+ return submission && submission.riskBand === "high";
+
+ }).length;
+
+ target.textContent = highRisk
+
+ ? `선택 ${ids.length}건 (고위험 ${highRisk}건)`
+
+ : `선택 ${ids.length}건`;
+
+}
+
+
+
+let isBulkDecisionInFlight = false;
+
+
+
+async function applyBulkDecision(decision) {
+
+ if (isBulkDecisionInFlight) return;
+
+ const ids = checkedBulkSubmissionIds();
+
+ const status = document.getElementById("bulk-decision-status");
+
+ const setStatus = (message) => {
+
+ if (status) status.textContent = message;
+
+ };
+
+ if (!ids.length) {
+
+ setStatus("판정할 케이스를 먼저 선택하세요.");
+
+ return;
+
+ }
+
+ const memo = document.getElementById("decision-memo")?.value.trim() || "";
+
+ if (decision === "rejected" && !memo) {
+
+ setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요.");
+
+ return;
+
+ }
+
+ const highRisk = ids.filter((id) => {
+
+ const submission = submissions.find((item) => item.id === id);
+
+ return submission && submission.riskBand === "high";
+
+ });
+
+ // Guardrail: approving a high-risk case in bulk is the one irreversible-feeling
+ // action here, so name the count instead of asking a generic "are you sure".
+
+ if (highRisk.length) {
+
+ const label = decisionLabels[decision] || decision;
+
+ if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) {
+
+ setStatus("일괄 판정을 취소했습니다.");
+
+ return;
+
+ }
+
+ }
+
+ isBulkDecisionInFlight = true;
+
+ setStatus(`${ids.length}건 판정 중…`);
+
+ try {
+
+ const payload = await apiJson("/api/submissions/decision-batch", {
+
+ method: "POST",
+
+ body: JSON.stringify({ submission_ids: ids, decision, memo }),
+
+ });
+
+ const result = payload.batchDecision || { applied: [], failed: [] };
+
+ applyBootstrap(payload);
+
+ renderAll();
+
+ const failures = result.failed || [];
+
+ setStatus(
+
+ failures.length
+
+ ? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures
+
+ .map((item) => `${item.submissionId}(${item.error})`)
+
+ .join(", ")}`
+
+ : `${(result.applied || []).length}건 판정을 적용했습니다.`,
+
+ );
+
+ } catch (errorValue) {
+
+ setStatus(`일괄 판정 실패: ${errorValue.message}`);
+
+ } finally {
+
+ isBulkDecisionInFlight = false;
+
+ }
+
+}
+
+
diff --git a/web/operator-gui/app-navigation.js b/web/operator-gui/app-navigation.js
new file mode 100644
index 0000000..00caee0
--- /dev/null
+++ b/web/operator-gui/app-navigation.js
@@ -0,0 +1,450 @@
+// Part 8 of 15 of the operator console app script.
+//
+// View/tab switching, case selection, decisions, manual search, rerun.
+//
+// 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 switchView(view, updateHash = true) {
+
+ if (view === "case" || view === "evidence") view = "workbench";
+
+ let knowledgeTabToApply = "";
+
+ if (view === "corrections") {
+
+ view = "knowledge";
+
+ // Setting state alone left the visible panel on whatever knowledge tab was
+ // last shown — the tab DOM is switched by switchKnowledgeTab, which must run
+ // (applied at the end, after the view panels are toggled).
+
+ knowledgeTabToApply = "corrections";
+
+ }
+
+ if (!viewNames.has(view)) return;
+
+ state.currentView = view;
+
+ // Stats are read on demand (a fresh cross-tab each open) rather than shipped
+ // in every bootstrap payload.
+
+ if (view === "stats") {
+
+ void loadDecisionStats();
+
+ }
+
+ document.querySelectorAll(".nav-button").forEach((button) => {
+
+ const active = button.dataset.view === view;
+
+ button.classList.toggle("active", active);
+
+ if (active) {
+
+ button.setAttribute("aria-current", "page");
+
+ } else {
+
+ button.removeAttribute("aria-current");
+
+ }
+
+ });
+
+
+
+ document.querySelectorAll("[data-view-panel]").forEach((panel) => {
+
+ const active = panel.dataset.viewPanel === view;
+
+ panel.classList.toggle("active", active);
+
+ panel.hidden = !active;
+
+ });
+
+ if (knowledgeTabToApply) {
+
+ switchKnowledgeTab(knowledgeTabToApply);
+
+ }
+
+ if (updateHash && window.location.hash.replace("#", "") !== view) {
+
+ window.history.replaceState(null, "", `#${view}`);
+
+ }
+
+}
+
+
+
+function switchKnowledgeTab(tab) {
+
+ const nextTab = ["collect", "registered", "manual", "corrections"].includes(tab) ? tab : "collect";
+
+ state.knowledgeTab = nextTab;
+
+ document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
+
+ const active = button.dataset.knowledgeTab === nextTab;
+
+ button.classList.toggle("active", active);
+
+ button.setAttribute("aria-selected", active ? "true" : "false");
+
+ });
+
+ document.querySelectorAll("[data-knowledge-panel]").forEach((panel) => {
+
+ const active = panel.dataset.knowledgePanel === nextTab;
+
+ panel.classList.toggle("active", active);
+
+ panel.hidden = !active;
+
+ });
+
+}
+
+
+
+function switchWorkbenchTab(tab) {
+
+ const nextTab = ["evidence", "queries"].includes(tab) ? tab : "evidence";
+
+ state.workbenchTab = nextTab;
+
+ document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
+
+ const active = button.dataset.workbenchTab === nextTab;
+
+ button.classList.toggle("active", active);
+
+ button.setAttribute("aria-selected", active ? "true" : "false");
+
+ });
+
+ document.querySelectorAll("[data-workbench-panel]").forEach((panel) => {
+
+ const panelName = panel.dataset.workbenchPanel;
+
+ const active = panelName === nextTab;
+
+ panel.classList.toggle("active", active);
+
+ panel.hidden = !active;
+
+ });
+
+}
+
+
+
+function selectCase(caseId) {
+
+ state.selectedCaseId = caseId;
+
+ suggestedQueryRunSummary = null;
+
+ // Reset per-case inputs. The decision memo is submitted with whatever case is
+ // selected at click time, so a memo typed for case A and left in the box was
+ // recorded verbatim as the rejection/correction reason for case B.
+
+ resetCaseScopedInputs();
+
+ switchView("workbench");
+
+ switchWorkbenchTab("evidence");
+ renderAll();
+
+}
+
+
+
+function resetCaseScopedInputs() {
+
+ const memo = document.getElementById("decision-memo");
+
+ if (memo) {
+
+ memo.value = "";
+
+ }
+
+ const memoError = document.getElementById("memo-error");
+
+ if (memoError) {
+
+ memoError.textContent = "";
+
+ }
+
+ const manualQuery = document.getElementById("manual-query");
+
+ if (manualQuery) {
+
+ manualQuery.value = "";
+
+ }
+
+ const manualStatus = document.getElementById("manual-query-status");
+
+ if (manualStatus) {
+
+ manualStatus.textContent = "";
+
+ }
+
+}
+
+
+
+let isDecisionInFlight = false;
+
+async function setDecision(decision, requiresMemo = false) {
+
+ // In-flight guard: a double-click, or clicking two different decisions
+ // quickly, would otherwise POST two conflicting decisions concurrently.
+ if (isDecisionInFlight) return;
+
+ const submission = getSelectedCase();
+
+ const memo = document.getElementById("decision-memo").value.trim();
+
+ const error = document.getElementById("memo-error");
+
+
+
+ if (!submission) {
+
+ error.textContent = "판정할 제출 이미지가 없습니다.";
+
+ return;
+
+ }
+
+
+
+ if (requiresMemo && !memo) {
+
+ error.textContent = "반려 또는 보정 결정에는 메모가 필요합니다.";
+
+ return;
+
+ }
+
+
+
+ error.textContent = "";
+
+ isDecisionInFlight = true;
+
+ try {
+
+ await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/decision`, {
+
+ method: "POST",
+
+ body: JSON.stringify({ decision, memo }),
+
+ });
+
+ document.getElementById("decision-memo").value = "";
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ error.textContent = errorValue.message;
+
+ } finally {
+
+ isDecisionInFlight = false;
+
+ }
+
+}
+
+
+
+let isManualSearchActive = false;
+
+async function runManualSearch(event) {
+
+ event.preventDefault();
+
+ if (isManualSearchActive) return;
+
+ const submission = getSelectedCase();
+
+ const provider = document.getElementById("manual-query-provider").value;
+
+ const queryInput = document.getElementById("manual-query");
+
+ const status = document.getElementById("manual-query-status");
+
+ const query = queryInput.value.trim();
+
+
+
+ if (!submission) {
+
+ status.textContent = "검색을 연결할 제출 이미지가 없습니다.";
+
+ return;
+
+ }
+
+
+
+ if (!query) {
+
+ status.textContent = "쿼리를 입력하세요.";
+
+ return;
+
+ }
+
+
+
+ const providerConfig = providers.find((item) => item.id === provider);
+
+ if (!providerConfig || !providerConfig.enabled) {
+
+ status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
+
+ return;
+
+ }
+
+
+
+ isManualSearchActive = true;
+
+ try {
+
+ const targetCaseId = submission.id;
+
+ await apiJson("/api/search/manual", {
+
+ method: "POST",
+
+ body: JSON.stringify({ submission_id: targetCaseId, provider, query }),
+
+ });
+
+ // The operator may have moved to another case while the search ran. Naming
+ // the case the evidence actually landed on prevents "added to the current
+ // case" from pointing at the wrong one, and the input is only cleared when
+ // it still belongs to the case we searched for.
+
+ if (state.selectedCaseId === targetCaseId) {
+
+ queryInput.value = "";
+
+ status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`;
+
+ } else {
+
+ status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`;
+
+ }
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ status.textContent = errorValue.message;
+
+ } finally {
+
+ isManualSearchActive = false;
+
+ }
+
+}
+
+
+
+function normalizeManualSearchProvider(provider) {
+
+ return window.OperatorSearch.normalizeManualSearchProvider(provider);
+
+}
+
+
+
+function rerunHistoricalQuery(button) {
+
+ const queryInput = document.getElementById("manual-query");
+
+ const providerSelect = document.getElementById("manual-query-provider");
+
+ const provider = normalizeManualSearchProvider(button.dataset.rerunProvider);
+
+ queryInput.value = button.dataset.rerunQuery || "";
+
+ providerSelect.value = provider;
+
+ void runManualSearch(new Event("submit"));
+
+}
+
+
+
+async function rerunEnrichment() {
+
+ const submission = getSelectedCase();
+
+ const button = document.getElementById("rerun-enrichment");
+
+ if (!submission) {
+
+ showApiError("재분석할 제출 이미지가 없습니다.");
+
+ return;
+
+ }
+
+ if (button) button.disabled = true;
+
+ try {
+
+ await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/rerun-enrichment`, {
+
+ method: "POST",
+
+ body: JSON.stringify({}),
+
+ });
+
+ await refreshFromApi();
+
+ } catch (errorValue) {
+
+ showApiError(errorValue.message);
+
+ } finally {
+
+ if (button) button.disabled = false;
+
+ }
+
+}
+
+
+
+function selectedBulkSubmissionIds() {
+
+ const ids = Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map((input) => input.dataset.bulkId);
+
+ return ids.length ? ids : state.selectedCaseId ? [state.selectedCaseId] : [];
+
+}
+
+
diff --git a/web/operator-gui/app-render-evidence.js b/web/operator-gui/app-render-evidence.js
new file mode 100644
index 0000000..a34fb43
--- /dev/null
+++ b/web/operator-gui/app-render-evidence.js
@@ -0,0 +1,449 @@
+// Part 6 of 15 of the operator console app script.
+//
+// Evidence grouping, ordering, and the evidence panel markup.
+//
+// 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 evidenceGroupDefinitions() {
+ return [
+ { id: "watchlist", label: "주의 후보 근거", description: "보류/반려 이력에서 생성된 주의 후보 또는 유사 이미지입니다.", tone: "strong" },
+ { id: "face_web", label: "얼굴 영역 웹 근거", description: "얼굴 영역만으로 찾은 웹 검색 결과입니다. 동일 인물 판정은 아닙니다.", tone: "search" },
+ { id: "full", label: "동일 이미지", description: "원본 이미지 직접 매칭", tone: "strong" },
+ { id: "partial", label: "부분 매칭", description: "일부 영역 또는 파생 이미지 매칭", tone: "strong" },
+ { id: "page", label: "출처 페이지", description: "같은 이미지가 포함된 웹페이지", tone: "strong" },
+ { id: "naver", label: "네이버 검색", description: "텍스트 기반 이미지 검색 결과", tone: "search" },
+ { id: "face", label: "얼굴/인물 감지", description: "동일 인물 판정은 아니며 얼굴 또는 사람 존재만 표시합니다.", tone: "local" },
+ { id: "provenance", label: "출처 표기", description: "파일에 들어 있던 권리 표기와 화면 캡처 신호입니다. 자기신고 정보이며 점수에 반영하지 않습니다.", tone: "local" },
+ { id: "visual", label: "유사 이미지", description: "시각적으로 유사한 참고 결과", tone: "weak" },
+ { id: "weak", label: "약한 라벨", description: "점수에 직접 반영하지 않는 힌트", tone: "weak" },
+ { id: "llm", label: "내부 요약", description: "출처 연결 요약 메모", tone: "note" },
+ { id: "internal", label: "내부 지문", description: "로컬 지문 기반 내부 처리 기록", tone: "local" },
+ { id: "failure", label: "실패/건너뜀", description: "외부 검색 도구 실패 또는 정책상 스킵", tone: "failure" },
+ ];
+}
+
+
+function evidenceGroupFor(evidence) {
+
+ if (evidence.knowledgeEntryStatus === "watchlist") return "watchlist";
+
+ if (evidence.faceCropSearch) return "face_web";
+
+ if (evidence.source === "google") {
+
+ if (evidence.matchType === "full") return "full";
+
+ if (evidence.matchType === "partial") return "partial";
+
+ if (evidence.matchType === "page") return "page";
+
+ if (evidence.matchType === "visual") return "visual";
+
+ if (evidence.matchType === "weak_label" || evidence.status === "weak") return "weak";
+
+ return "visual";
+
+ }
+
+ if (evidence.source === "naver") return "naver";
+
+ if (evidence.source === "face") return "face";
+
+ if (evidence.source === "provenance") return "provenance";
+
+ if (evidence.source === "llm") return "llm";
+
+ if (evidence.source === "failure") return "failure";
+
+ return "internal";
+
+}
+
+
+
+function groupedEvidence(evidence) {
+
+ return evidence.reduce((collection, item) => {
+
+ const group = evidenceGroupFor(item);
+
+ if (!collection[group]) collection[group] = [];
+
+ collection[group].push(item);
+
+ return collection;
+
+ }, {});
+
+}
+
+
+
+function evidencePriorityScore(evidence) {
+
+ const groupPriority = {
+
+ full: 100,
+
+ watchlist: 95,
+
+ partial: 85,
+
+ page: 75,
+
+ naver: 65,
+
+ face_web: 58,
+
+ face: 55,
+
+ visual: 35,
+
+ weak: 10,
+
+ llm: 8,
+
+ internal: 6,
+
+ failure: 4,
+
+ };
+
+ return (groupPriority[evidenceGroupFor(evidence)] || 0) + Number(evidence.confidence || 0);
+
+}
+
+
+
+function sortEvidenceItems(items) {
+
+ return [...items].sort((left, right) => evidencePriorityScore(right) - evidencePriorityScore(left));
+
+}
+
+
+
+function renderRerunDiffSummary(diff) {
+ const scoreBefore = Number(diff.scoreBefore || 0);
+ const scoreAfter = Number(diff.scoreAfter || 0);
+ const delta = scoreAfter - scoreBefore;
+ const direction = delta > 0 ? `상승 ${delta}` : delta < 0 ? `하락 ${Math.abs(delta)}` : "변동 없음";
+ const removedSummaries = diff.removedSummaries || [];
+ const removedBlock = removedSummaries.length
+ ? `
+
+ 이번 재분석에서 제거됨 · ${removedSummaries.length}개
+
+ ${removedSummaries
+ .map((item) => `- ${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}
`)
+ .join("")}
+
+
+ `
+ : "";
+ return `
+
+ 재분석 ${escapeHtml(diff.at || "")}
+ 점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})
+ 신규 증거 ${(diff.addedEvidenceIds || []).length}개
+ ${removedBlock}
+
+ `;
+}
+
+
+function renderEvidenceSummary(submission) {
+ const groups = groupedEvidence(submission.evidence);
+ const count = (group) => (groups[group] || []).length;
+ const directCount = count("full") + count("partial") + count("page");
+ const searchCount = directCount + count("naver");
+ const primaryJudgment = directCount
+ ? "직접 매칭 우선 확인"
+ : count("visual")
+ ? "유사 이미지 참고 확인"
+ : count("face")
+ ? "인물 신호만 확인됨"
+ : "증거 부족";
+ const summaryCards = [
+ ["동일", count("full")],
+ ["부분", count("partial")],
+ ["페이지", count("page")],
+ ["유사", count("visual")],
+ ["네이버", count("naver")],
+ ["인물", count("face")],
+ ];
+
+ return `
+
+
+
${riskLabels[submission.riskBand]} ${submission.riskScore}
+
+ ${escapeHtml(primaryJudgment)}
+ ${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거
+
+
+
+ ${summaryCards
+ .map(
+ ([label, value]) => `
+
+ ${escapeHtml(label)}
+ ${value}
+
+ `,
+ )
+ .join("")}
+
+
+ `;
+}
+
+
+
+function renderEvidenceGroups(submission) {
+ if (!submission.evidence.length) {
+ return `아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.
`;
+ }
+
+ const groups = groupedEvidence(submission.evidence);
+ return evidenceGroupDefinitions()
+ .filter((group) => groups[group.id]?.length)
+ .map((group) => renderEvidenceGroup(group, sortEvidenceItems(groups[group.id])))
+ .join("");
+}
+
+
+function renderEvidenceGroup(group, items) {
+
+ const topItems = items.slice(0, 3);
+
+ const detailItems = items.slice(3);
+
+ return `
+
+
+
+
+
+
+
+
${escapeHtml(group.label)}
+
+ ${escapeHtml(group.description)}
+
+
+
+
${items.length}개
+
+
+
+
+
+ ${topItems.map(renderEvidenceRow).join("")}
+
+
+
+ ${
+
+ detailItems.length
+
+ ? `
+
+
+
+ 자세히 보기 · ${detailItems.length}개
+
+
+
+ ${detailItems.map(renderEvidenceRow).join("")}
+
+
+
+
+
+ `
+
+ : ""
+
+ }
+
+
+
+ `;
+
+}
+
+
+
+function renderEvidenceRow(evidence) {
+ const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
+ const newChip = isNewFromRerun ? `신규` : "";
+ const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
+ const sourceIds = sourceEvidenceIds.length
+ ? `근거 ID: ${sourceEvidenceIds.join(", ")}`
+ : "근거 ID 없음";
+ const contribution = evidence.contributed ? "점수 반영" : "참고";
+ const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
+ const operatorStatus = normalizedOperatorStatus
+ ? `${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}`
+ : "";
+ const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
+ const matchType = evidence.matchType ? `${escapeHtml(formatMatchType(evidence.matchType))}` : "";
+ const pageTitle = evidence.pageTitle ? `${escapeHtml(evidence.pageTitle)}` : "";
+ const faceCropMeta = evidence.faceCropSearch
+ ? `얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}`
+ : "";
+ const watchlistMeta =
+ evidence.knowledgeEntryStatus === "watchlist"
+ ? `주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}`
+ : "";
+ const providerScore =
+ evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `검색 도구 점수 ${escapeHtml(evidence.providerScore)}` : "";
+ const similarity =
+ evidence.similarity !== undefined && evidence.similarity !== ""
+ ? `이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}`
+ : "";
+ const queryStrategy = evidence.queryStrategy ? `${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}` : "";
+ const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
+ const searchType = evidence.searchType ? `${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}` : "";
+ const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
+ const preview = renderEvidencePreview(evidence);
+ const link = renderEvidenceLink(evidence);
+ const statusButtons = [
+ ["used_for_judgment", "사용"],
+ ["ignored", "미사용"],
+ ]
+ .map(
+ ([status, label]) => `
+
+ `,
+ )
+ .join("");
+
+ return `
+
+ ${preview}
+
+
+ ${escapeHtml(sourceLabels[evidence.source] || evidence.source)}
+ ${newChip}
+ ${escapeHtml(formatEvidenceTitle(evidence))}
+
+
+ 신뢰도 ${escapeHtml(confidence)}
+ ${escapeHtml(contribution)}
+ ${operatorStatus}
+ ${matchType}
+ ${faceCropMeta}
+ ${watchlistMeta}
+ ${providerScore}
+ ${similarity}
+ ${queryStrategy}
+ ${searchType}
+ ${pageTitle}
+ ${escapeHtml(evidence.query || "쿼리 없음")}
+ ${escapeHtml(formatDomain(evidence.domain))}
+ ${escapeHtml(evidence.retrievedAt)}
+ ${escapeHtml(sourceIds)}
+
+ ${link}
+
+
+ ${statusButtons}
+
+
+ `;
+}
+
+
+function renderEvidencePreview(evidence) {
+
+ const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
+
+ const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
+
+ if (!previewUrl) return ``;
+
+ return `
+
+
+
+
+
+
+
+ `;
+
+}
+
+
+
+function renderEvidenceLink(evidence) {
+
+ const url = evidence.url || evidence.imageUrl || "";
+
+ if (!url) return "";
+
+ const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
+
+ return `
+
+
+
+ ${escapeHtml(label)}
+
+
+
+ `;
+
+}
+
+
+
+function renderEvidenceSearch() {
+ const submission = getSelectedCase();
+ const queryHistory = document.getElementById("query-history");
+ const results = document.getElementById("search-results");
+
+ if (!submission) {
+ document.getElementById("query-case-label").textContent = "-";
+ queryHistory.innerHTML = `표시할 케이스가 없습니다.
`;
+ results.innerHTML = `API 연결 후 검색 근거를 확인할 수 있습니다.
`;
+ return;
+ }
+
+ document.getElementById("query-case-label").textContent = submission.id;
+ queryHistory.innerHTML = submission.queryHistory.length
+ ? submission.queryHistory
+ .map(
+ (query) => `
+
+
+ ${escapeHtml(providerLabels[query.provider] || query.provider)}
+ ${escapeHtml(query.query)}
+
+
+ ${escapeHtml(formatQueryStatus(query.status))}
+ ${query.strategy ? `${escapeHtml(formatQueryStrategy(query.strategy))}` : ""}
+ ${query.source ? `출처 ${escapeHtml(query.source)}` : ""}
+ ${escapeHtml(query.timestamp)}
+ ${query.count}건
+
+
+
+ `,
+ )
+ .join("")
+ : `아직 실행한 검색 쿼리가 없습니다.
`;
+
+ activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
+ const searchableEvidence = searchableEvidenceItems(submission);
+ results.innerHTML = searchableEvidence.length
+ ? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
+ : `현재 케이스에는 검색 결과가 없습니다.
`;
+}
+
diff --git a/web/operator-gui/app-render-panels.js b/web/operator-gui/app-render-panels.js
new file mode 100644
index 0000000..b85e917
--- /dev/null
+++ b/web/operator-gui/app-render-panels.js
@@ -0,0 +1,364 @@
+// 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"
+ ? `원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}미래 검출 ${escapeHtml(entry.contributionCount || 0)}건`
+ : "";
+ const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `${escapeHtml(entry.memo)}` : "";
+ const memoBlock = memoInline ? "" : `${escapeHtml(entry.memo)}
`;
+ const lifecycleActions =
+ entryStatus === "watchlist"
+ ? `
+
+
+ `
+ : entryStatus === "excluded"
+ ? ""
+ : entry.active
+ ? ``
+ : ``;
+ const editForm =
+ editingKnowledgeEntryId === entry.id
+ ? `
+
+ `
+ : "";
+ return `
+
+ ${
+ entry.imageAsset
+ ? `
`
+ : ``
+ }
+
+
+ ${escapeHtml(entry.name)}
+
+
+
+ ${escapeHtml(formatProvenance(entry.provenance))}
+ ${escapeHtml(formatKnowledgeStatus(entryStatus))}
+
+
+ ${escapeHtml(formatKnowledgeType(entry.type))}
+ 별칭: ${escapeHtml(aliases.join(", ") || "-")}
+ 키워드: ${escapeHtml(keywords.join(", ") || "-")}
+ 샘플 지문 ${(entry.sampleFingerprints || []).length}
+ ${statusMeta}
+ ${memoInline}
+
+
+ ${memoBlock}
+ ${editForm}
+
+
+
+ ${lifecycleActions}
+
+
+ `;
+ })
+ .join("")
+ : `조건에 맞는 기준 항목이 없습니다.
`;
+ 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 `
+
+
+ ${
+ candidate.imageAsset
+ ? `
`
+ : ``
+ }
+
+
+ ${escapeHtml(providerLabels[candidate.provider] || candidate.provider || "검색")}
+ ${escapeHtml(candidate.title || candidate.query)}
+
+
+ ${escapeHtml(candidate.query)}
+ ${escapeHtml(formatCandidateSourceType(candidate.sourceCandidateType))}
+ 순위 ${escapeHtml(candidate.rank || "-")}
+ ${escapeHtml(formatCandidateStatus(candidate.status))}
+
+ ${
+ candidate.sourceUrl
+ ? `
출처 열기`
+ : ""
+ }
+
+
+
+
+
+
+ `;
+ })
+ .join("")
+ : `검색어와 제공자를 선택하면 기준 DB 후보가 표시됩니다.
`;
+}
+
+
+
+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) => `
+
+
+
+ 보정
+ ${escapeHtml(correction.decisionId)} · ${escapeHtml(correction.submissionId)}
+
+
+ ${escapeHtml(correction.previous)} -> ${escapeHtml(correction.current)}
+ ${escapeHtml(correction.operator)}
+ ${escapeHtml(correction.timestamp)}
+
+
${escapeHtml(correction.reason)}
+
+ ${(correction.derivedEntries || [])
+ .map(
+ (entry) =>
+ `${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}`,
+ )
+ .join("")}
+
+
+
+
+ `,
+ )
+ .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 `환경 변수 확인됨: ${escapeHtml(requiredEnv.join(", "))}`;
+ }
+ return `누락된 환경 변수: ${escapeHtml(missing.join(", "))}`;
+}
+
+
+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 `
+
+
+
+
+ ${escapeHtml(providerName)}
+
+
+ ${provider.enabled ? "활성" : "중지"}
+ ${escapeHtml(formatReason(provider.compliance))}
+ 최근 성공: ${escapeHtml(formatReason(provider.lastSuccess))}
+ 최근 실패: ${escapeHtml(formatReason(provider.lastFailure))}
+
+
${escapeHtml(formatReason(provider.boundary))}
+ ${envStatus}
+
+
+
${provider.usage} / ${provider.quota == null ? "무제한" : provider.quota}${percent}%
+
+
+
+
+
+
+
+ `;
+ })
+ .join("");
+}
+
+
+function renderAuditLog() {
+
+ document.getElementById("audit-body").innerHTML = auditEvents
+
+ .map(
+
+ (event) => `
+
+
+
+ | ${escapeHtml(event.timestamp)} |
+
+ ${escapeHtml(event.actor)} |
+
+ ${escapeHtml(formatReason(event.event))} |
+
+ ${escapeHtml(formatReason(event.object))} |
+
+ ${escapeHtml(formatReason(event.change))} |
+
+
+
+ `,
+
+ )
+
+ .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 = `
+
+
+
+ 외부 검색 tool ${visibleProviders.filter((provider) => provider.enabled).length}/${visibleProviders.length} 활성 · 실패 ${failed}
+
+ `;
+
+}
+
+
+
+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 반영";
+ });
+}
+
diff --git a/web/operator-gui/app-render-queue.js b/web/operator-gui/app-render-queue.js
new file mode 100644
index 0000000..57fdb0f
--- /dev/null
+++ b/web/operator-gui/app-render-queue.js
@@ -0,0 +1,322 @@
+// Part 5 of 15 of the operator console app script.
+//
+// Queue list, import status, and the case review shell.
+//
+// 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 renderQueue() {
+
+ const rows = filteredSubmissions();
+
+ const queueLabel = state.submissionQueue?.label || "";
+
+ const queueMeta = queueLabel ? ` (${escapeHtml(queueLabel)})` : "";
+
+ const body = document.getElementById("queue-body");
+
+
+
+ document.getElementById("queue-health").innerHTML = state.apiError
+
+ ? `
+
+
+
+ ${escapeHtml(state.apiError)}
+
+ `
+
+ : `
+
+
+
+ ${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta}
+
+ `;
+
+
+
+ if (!rows.length) {
+
+ body.innerHTML = `
+
+
+
+ |
+
+ 현재 필터에 맞는 심사 건이 없습니다.
+
+ |
+
+
+
+ `;
+
+ return;
+
+ }
+
+
+
+ body.innerHTML = rows
+
+ .map((submission) => {
+
+ const providerHtml = visibleProviderStateEntries(submission.providerState)
+
+ .map(
+
+ ([provider, status]) => {
+
+ const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
+
+ return `${escapeHtml(formatQueueProviderStatus(provider, status))}`;
+
+ },
+
+ )
+
+ .join("");
+
+ const reasonHtml = submission.reasons
+
+ .slice(0, 2)
+
+ .map((reason) => `${escapeHtml(formatReason(reason))}`)
+
+ .join("");
+
+ const submittedTime = formatQueueTimestamp(submission.submittedAt);
+
+ return `
+
+
+
+ |
+
+ }) |
+
+
+
+
+
+
+
+ ${duplicateBadgeHtml(submission)}
+
+
+
+ |
+
+
+
+
+
+ ${riskLabels[submission.riskBand]} ${submission.riskScore}
+
+
+
+ |
+
+ ${reasonHtml} |
+
+ ${providerHtml} |
+
+ ${escapeHtml(submission.applicantStatus)} |
+
+ ${decisionLabels[submission.decisionStatus]} |
+
+
+
+
+
+ ${escapeHtml(submittedTime)}
+
+
+
+ |
+
+
+
+ `;
+
+ })
+
+ .join("");
+
+}
+
+
+
+function renderImportStatus() {
+
+ const target = document.getElementById("submission-import-status");
+
+ if (!target) return;
+
+ const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
+
+ target.innerHTML = `
+
+
+
+ ${escapeHtml(state.importStatus.message)}
+
+ `;
+
+}
+
+
+
+function renderNoSelectedCase() {
+ document.getElementById("case-title").textContent = "선택된 제출 이미지가 없습니다";
+ document.getElementById("case-image").removeAttribute("src");
+ document.getElementById("case-image").alt = "";
+ document.getElementById("image-derivative-note").textContent = "API 연결 후 제출 이미지를 불러오면 이 영역에 분석 대상이 표시됩니다.";
+ document.getElementById("case-score").className = "score-pill risk-pending";
+ document.getElementById("case-score").textContent = "대기";
+ document.getElementById("floating-case-score").className = "score-pill risk-pending";
+ document.getElementById("floating-case-score").textContent = "대기";
+ document.getElementById("file-facts").innerHTML = "";
+ document.getElementById("similar-strip").innerHTML = "";
+ document.getElementById("face-crop-strip").innerHTML = "";
+ activeRerunAddedIds = new Set();
+ document.getElementById("rerun-diff-summary").innerHTML = "";
+ document.getElementById("case-reasons").innerHTML = `표시할 케이스가 없습니다.
`;
+ document.getElementById("evidence-groups").innerHTML = `API 연결 실패 또는 제출 이미지 없음 상태입니다.
`;
+ document.getElementById("recommendation-box").innerHTML = `실제 API 데이터가 로드될 때까지 판정하지 않습니다.`;
+ document.getElementById("derived-preview").innerHTML = `자동 후보 생성 없음`;
+ // Clear the evidence-gap recommendations too: they belong to the case that was
+ // just deselected, and leaving them rendered invited running suggested queries
+ // against nothing.
+ const nextActions = document.getElementById("evidence-next-actions");
+ if (nextActions) nextActions.innerHTML = "";
+ const llmDraft = document.getElementById("llm-draft");
+ if (llmDraft) {
+ llmDraft.hidden = true;
+ llmDraft.innerHTML = "";
+ }
+}
+
+
+function renderCaseReview() {
+
+ const submission = getSelectedCase();
+
+ if (!submission) {
+
+ renderNoSelectedCase();
+
+ return;
+
+ }
+
+ renderLlmDraft(submission);
+
+ document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
+
+ const caseImageElement = document.getElementById("case-image");
+
+ caseImageElement.src = safeUrl(submission.asset);
+
+ caseImageElement.alt = `${submission.id} 제출 이미지`;
+
+ // Reset the view controls (rotate / fit) so a new case starts unrotated.
+ caseImageElement.dataset.rotation = "0";
+
+ caseImageElement.style.transform = "";
+
+ caseImageElement.style.objectFit = "";
+
+ document.getElementById("image-derivative-note").textContent = submission.derivativeNote;
+
+ document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
+
+ document.getElementById("case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
+
+ document.getElementById("floating-case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
+
+ document.getElementById("floating-case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
+
+ document.getElementById("file-facts").innerHTML = Object.entries(submission.fileFacts)
+
+ .map(([label, value]) => `${escapeHtml(formatFactLabel(label))}${escapeHtml(value)}
`)
+
+ .join("");
+
+
+
+ document.getElementById("similar-strip").innerHTML = submission.similar
+
+ .map(
+
+ (item) => `
+
+
+
+
)})
+
+
${escapeHtml(formatReason(item.label))}
+
+
+
+ `,
+
+ )
+
+ .join("");
+
+ document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
+ .map(
+ (crop) => `
+
+
+ 얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다
+
+ `,
+ )
+ .join("");
+
+ activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
+ document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff
+ ? renderRerunDiffSummary(submission.lastRerunDiff)
+ : "";
+
+ document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
+
+ document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
+
+
+ document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission);
+
+ document.getElementById("recommendation-box").innerHTML = `
+
+ ${riskLabels[submission.riskBand]}
+
+ ${escapeHtml(submission.recommendation.label)}
+
+ ${escapeHtml(formatReason(submission.recommendation.detail))}
+
+ 현재 운영 결정: ${decisionLabels[submission.decisionStatus]}
+
+ `;
+
+ document.getElementById("derived-preview").innerHTML = `
+
+ ${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}
+
+ ${escapeHtml(submission.derivedPreview.entryName)}
+
+ ${escapeHtml(submission.derivedPreview.effect)}
+
+ `;
+
+}
+
+
diff --git a/web/operator-gui/app-selectors.js b/web/operator-gui/app-selectors.js
new file mode 100644
index 0000000..15a3f1f
--- /dev/null
+++ b/web/operator-gui/app-selectors.js
@@ -0,0 +1,310 @@
+// 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 `
+
+
+ 근거 보강 추천
+ 추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.
+
+
+ ${reasons.map((reason) => `- ${escapeHtml(reason)}
`).join("")}
+
+
+ ${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;
+
+ });
+
+}
+
+
diff --git a/web/operator-gui/app-state.js b/web/operator-gui/app-state.js
new file mode 100644
index 0000000..9a87986
--- /dev/null
+++ b/web/operator-gui/app-state.js
@@ -0,0 +1,137 @@
+// Part 1 of 15 of the operator console app script.
+//
+// Label imports and the in-memory client state every other part reads.
+//
+// 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.
+
+const {
+ riskLabels,
+ decisionLabels,
+ providerLabels,
+ sourceLabels,
+ providerStatusLabels,
+ candidateStatusLabels,
+ evidenceStatusLabels,
+ knowledgeStatusLabels,
+ provenanceLabels,
+ knowledgeTypeLabels,
+} = window.OperatorLabels;
+
+const retiredProviderIds = new Set();
+const operatorSearchProviders = [
+ { id: "naver", label: providerLabels.naver },
+ { id: "google_search", label: providerLabels.google_search },
+];
+const submissions = [];
+
+
+
+const providers = [];
+
+
+
+const knowledgeEntries = [];
+
+
+
+const searchCoverage = {};
+
+let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과
+
+const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
+let editingKnowledgeEntryId = "";
+
+let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds
+
+const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({
+
+ coverageGoodRate: 70,
+
+ coverageWarnRate: 40,
+
+ queryGoodRate: 70,
+
+ queryWarnRate: 40,
+
+});
+
+const coverageThresholds = { ...DEFAULT_COVERAGE_THRESHOLDS };
+
+
+
+const collectionCandidates = [];
+
+
+
+const corrections = [];
+
+
+
+const auditEvents = [];
+
+
+
+const decisionMemoPresets = [];
+
+
+
+const state = {
+
+ currentView: "queue",
+
+ workbenchTab: "evidence",
+
+ knowledgeTab: "collect",
+
+ currentCollectionQuery: "",
+
+ currentCollectionProvider: "",
+
+ selectedCaseId: submissions[0]?.id || "",
+
+ apiError: "",
+
+ submissionQueue: null,
+
+ importStatus: {
+
+ tone: "idle",
+
+ message: "로컬 제출 저장소 변경 대기 중",
+
+ },
+
+ filters: {
+
+ risk: "all",
+
+ source: "all",
+
+ decision: "all",
+
+ sort: "risk",
+
+ query: "",
+
+ },
+
+};
+
+
+
+const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stats", "audit"]);
+
+
+
+// Bearer token for a server started with COPYRIGHTER_AUTH_TOKEN. Held in memory
+// only (never localStorage/sessionStorage): the first authenticated request makes
+// the server mint an HttpOnly session cookie, which is what subsequently
+// authorizes
/media requests that cannot carry a header.
+
+let authToken = "";
+
+let pendingAuthPrompt = null;
+
+
diff --git a/web/operator-gui/app-stats.js b/web/operator-gui/app-stats.js
new file mode 100644
index 0000000..506d7a5
--- /dev/null
+++ b/web/operator-gui/app-stats.js
@@ -0,0 +1,291 @@
+// Part 11 of 15 of the operator console app script.
+//
+// Decision statistics and calibration rendering.
+//
+// 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.
+
+// --- Decision statistics / calibration ----------------------------------
+
+
+
+const DECISION_STAT_LABELS = {
+
+ approved: "승인",
+
+ held: "보류",
+
+ rejected: "반려",
+
+ corrected: "보정",
+
+ unreviewed: "미심사",
+
+};
+
+const BAND_STAT_LABELS = {
+
+ high: "고위험",
+
+ medium: "중위험",
+
+ low: "저위험",
+
+ failed: "분석실패",
+
+ pending: "대기",
+
+};
+
+
+
+async function loadDecisionStats() {
+
+ const body = document.getElementById("stats-body");
+
+ if (!body) return;
+
+ body.innerHTML = `통계를 불러오는 중…
`;
+
+ try {
+
+ const stats = await apiJson("/api/stats/decisions");
+
+ renderDecisionStats(stats);
+
+ } catch (errorValue) {
+
+ body.innerHTML = `통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}
`;
+
+ }
+
+}
+
+
+
+function calibrationTone(rate, decided) {
+
+ // Not enough decisions to judge -> neutral, don't cry miscalibration on n=1.
+
+ if (decided < 5) return "neutral";
+
+ if (rate >= 0.4) return "bad";
+
+ if (rate >= 0.2) return "warn";
+
+ return "good";
+
+}
+
+
+
+function renderDecisionStats(stats) {
+
+ const body = document.getElementById("stats-body");
+
+ if (!body) return;
+
+ const cal = stats.calibration || {};
+
+ const throughput = stats.throughput || { byDay: [] };
+
+ const backlog = stats.backlog || { unreviewed: 0, total: 0 };
+
+
+
+ const overTone = calibrationTone(cal.highBandApprovalRate || 0, cal.highBandDecided || 0);
+
+ const underTone = calibrationTone(cal.lowBandRejectionRate || 0, cal.lowBandDecided || 0);
+
+
+
+ const tiles = `
+
+
+
+
+
+ 과탐 — 고위험인데 승인
+
+ ${formatPercent(cal.highBandApprovalRate)}
+
+ 고위험 판정 ${cal.highBandDecided || 0}건 기준
+
+
+
+
+
+ 미탐 — 저위험인데 반려
+
+ ${formatPercent(cal.lowBandRejectionRate)}
+
+ 저위험 판정 ${cal.lowBandDecided || 0}건 기준
+
+
+
+
+
+ 미심사 백로그
+
+ ${backlog.unreviewed}
+
+ 전체 ${backlog.total}건 중
+
+
+
+
+
+ 누적 판정
+
+ ${throughput.totalDecisions || 0}
+
+ 감사 로그 기준
+
+
+
+
+
+ `;
+
+
+
+ body.innerHTML = `
+
+ ${tiles}
+
+
+
+
+
+ 밴드 × 판정 매트릭스
+
+ 각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.
+
+ ${renderStatsMatrix(stats)}
+
+
+
+
+
+ 최근 결정 추이
+
+ 최근 14일 일별 판정 건수.
+
+ ${renderThroughputBars(throughput.byDay || [])}
+
+
+
+
+
+ `;
+
+}
+
+
+
+function renderStatsMatrix(stats) {
+
+ const bands = stats.bands || [];
+
+ const decisions = stats.decisions || [];
+
+ const matrix = stats.matrix || {};
+
+ const head = decisions.map((d) => `${escapeHtml(DECISION_STAT_LABELS[d] || d)} | `).join("");
+
+ const rows = bands
+
+ .map((band) => {
+
+ const row = matrix[band] || {};
+
+ const total = decisions.reduce((sum, d) => sum + (row[d] || 0), 0);
+
+ const cells = decisions
+
+ .map((d) => {
+
+ const value = row[d] || 0;
+
+ const strong = value > 0 ? " has-value" : "";
+
+ return `${value} | `;
+
+ })
+
+ .join("");
+
+ return `| ${escapeHtml(BAND_STAT_LABELS[band] || band)} | ${cells}${total} |
`;
+
+ })
+
+ .join("");
+
+ return `
+
+
+
+
+
+ | 밴드 \\ 판정 | ${head}합계 |
+
+ ${rows}
+
+
+
+
+
+ `;
+
+}
+
+
+
+function renderThroughputBars(byDay) {
+
+ if (!byDay.length) {
+
+ return `아직 기록된 판정이 없습니다.
`;
+
+ }
+
+ const max = Math.max(...byDay.map((entry) => entry.count), 1);
+
+ const bars = byDay
+
+ .map((entry) => {
+
+ const height = Math.round((entry.count / max) * 100);
+
+ return `
+
+
+
+
+
+
${entry.count}
+
+
${escapeHtml(entry.day.slice(5))}
+
+
+
+ `;
+
+ })
+
+ .join("");
+
+ return `${bars}
`;
+
+}
+
+
+
+function formatPercent(rate) {
+
+ return `${Math.round((Number(rate) || 0) * 100)}%`;
+
+}
+
+
diff --git a/web/operator-gui/app-submissions.js b/web/operator-gui/app-submissions.js
new file mode 100644
index 0000000..6441230
--- /dev/null
+++ b/web/operator-gui/app-submissions.js
@@ -0,0 +1,382 @@
+// Part 12 of 15 of the operator console app script.
+//
+// Submission reload/import/upload and knowledge entry creation.
+//
+// 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.
+
+async function rerunSelectedEnrichment() {
+
+ const ids = selectedBulkSubmissionIds();
+
+ const button = document.getElementById("bulk-rerun");
+
+ if (!ids.length) {
+
+ setImportStatus("재분석할 제출이 없습니다.", "bad");
+ return;
+
+ }
+
+
+
+ if (button) button.disabled = true;
+
+ setImportStatus(`${ids.length}건 재분석을 시작합니다.`, "warn");
+ try {
+
+ for (const [index, submissionId] of ids.entries()) {
+
+ setImportStatus(`${index + 1}/${ids.length} 재분석 중 · ${submissionId}`, "warn");
+ await apiJson(`/api/submissions/${encodeURIComponent(submissionId)}/rerun-enrichment`, {
+
+ method: "POST",
+
+ body: JSON.stringify({}),
+
+ });
+
+ }
+
+ state.selectedCaseId = ids[0];
+
+ await refreshFromApi();
+
+ setImportStatus(`${ids.length}건 재분석 완료`, "ok");
+ } catch (errorValue) {
+
+ // Best-effort refresh so already-rerun submissions aren't left stale in the
+ // UI when the loop fails partway through.
+ try { await refreshFromApi(); } catch (_refreshError) { /* keep original error */ }
+
+ setImportStatus(errorValue.message, "bad");
+
+ } finally {
+
+ if (button) button.disabled = false;
+
+ }
+
+}
+
+
+
+function resetQueueFiltersForImport() {
+
+ state.filters.risk = "all";
+
+ state.filters.source = "all";
+
+ state.filters.decision = "all";
+
+ state.filters.sort = "newest";
+
+ state.filters.query = "";
+
+ document.querySelectorAll("#risk-filter button").forEach((button) => {
+
+ button.classList.toggle("active", button.dataset.risk === "all");
+
+ });
+
+ document.getElementById("source-filter").value = "all";
+
+ document.getElementById("decision-filter").value = "all";
+
+ document.getElementById("queue-sort").value = "newest";
+
+ document.getElementById("queue-search").value = "";
+
+}
+
+
+
+function importedFolderStatusMessage(payload, fallbackLabel) {
+
+ return window.OperatorSubmissionImport.importedFolderStatusMessage(payload, fallbackLabel);
+
+}
+
+
+
+function importedSubmissionStatusMessage(payload, submissionId) {
+
+ return window.OperatorSubmissionImport.importedSubmissionStatusMessage(payload, submissionId);
+
+}
+
+
+
+async function reloadSubmissions() {
+
+ const button = document.getElementById("reload-submissions");
+
+ const folderInput = document.getElementById("submission-folder");
+
+ const folderPath = (folderInput?.value || "").trim();
+
+ const endpoint = folderPath ? "/api/submissions/import-folder" : "/api/submissions/reload";
+
+ const body = folderPath ? { path: folderPath } : {};
+
+ button.disabled = true;
+
+ setImportStatus("제출 폴더를 읽는 중입니다.", "warn");
+
+ try {
+
+ const payload = await apiJson(endpoint, {
+
+ method: "POST",
+
+ body: JSON.stringify(body),
+
+ });
+
+ // Snapshot the pre-reload ids BEFORE applyBootstrap replaces `submissions`,
+ // otherwise the imported-diff below always compares against the new list and
+ // is always empty (no new case is ever auto-selected).
+ const existingIds = new Set(submissions.map((submission) => submission.id));
+
+ applyBootstrap(payload);
+
+ if (folderPath) {
+
+ folderInput.value = "";
+
+ const importedFolderSubmissions = payload.submissions || [];
+
+ if (importedFolderSubmissions[0]) {
+
+ state.selectedCaseId = importedFolderSubmissions[0].id;
+
+ resetQueueFiltersForImport();
+
+ setImportStatus(importedFolderStatusMessage(payload, folderPath), "ok");
+
+ } else {
+
+ setImportStatus("추가된 제출 없음", "idle");
+
+ }
+
+ } else {
+
+ const importedSubmissions = (payload.submissions || []).filter((submission) => !existingIds.has(submission.id));
+
+ if (importedSubmissions.length) {
+
+ state.selectedCaseId = importedSubmissions[0].id;
+
+ resetQueueFiltersForImport();
+
+ setImportStatus(importedSubmissionStatusMessage(payload, importedSubmissions[0].id), "ok");
+
+ } else {
+
+ setImportStatus("추가된 제출 없음", "idle");
+
+ }
+
+ }
+
+ renderAll();
+
+ } catch (errorValue) {
+
+ setImportStatus(errorValue.message, "bad");
+
+ } finally {
+
+ button.disabled = false;
+
+ }
+
+}
+
+
+function updateSubmissionImageName() {
+
+ const input = document.getElementById("submission-image");
+
+ const target = document.getElementById("submission-image-name");
+
+ if (!input || !target) return;
+
+ target.textContent = input.files[0]?.name || "선택된 파일 없음";
+
+}
+
+
+
+async function uploadSubmissionImage() {
+
+ const input = document.getElementById("submission-image");
+
+ const button = document.getElementById("upload-submission-image");
+
+ const file = input?.files?.[0];
+
+ if (!file) {
+
+ setImportStatus("추가할 사진을 선택하세요.", "bad");
+
+ return;
+
+ }
+
+ button.disabled = true;
+
+ setImportStatus("사진을 현재 제출 폴더에 넣는 중입니다.", "warn");
+
+ try {
+
+ const image = await readKnowledgeImage(file);
+
+ const payload = await apiJson("/api/submissions/upload-image", {
+
+ method: "POST",
+
+ body: JSON.stringify({ image }),
+
+ });
+
+ applyBootstrap(payload);
+
+ input.value = "";
+
+ updateSubmissionImageName();
+
+ if (payload.uploadedSubmissionId) {
+
+ state.selectedCaseId = payload.uploadedSubmissionId;
+
+ resetQueueFiltersForImport();
+
+ switchView("workbench");
+
+ switchWorkbenchTab("evidence");
+
+ }
+
+ setImportStatus(`${payload.uploadedSubmissionId || file.name} 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다.`, "ok");
+
+ renderAll();
+
+ } catch (errorValue) {
+
+ setImportStatus(errorValue.message, "bad");
+
+ } finally {
+
+ button.disabled = false;
+
+ }
+
+}
+
+
+
+async function readKnowledgeImage(file) {
+
+ return window.OperatorSubmissionImport.fileToImagePayload(file);
+
+}
+
+
+function updateKnowledgeImageName() {
+
+ const input = document.getElementById("knowledge-image");
+
+ const target = document.getElementById("knowledge-image-name");
+
+ if (!input || !target) return;
+
+ target.textContent = input.files[0]?.name || "선택된 파일 없음";
+
+}
+
+
+
+async function addKnowledgeEntry(event) {
+
+ event.preventDefault();
+
+ const name = document.getElementById("knowledge-name").value.trim();
+
+ const type = document.getElementById("knowledge-type").value;
+
+ const aliases = document
+
+ .getElementById("knowledge-aliases")
+
+ .value.split(",")
+
+ .map((item) => item.trim())
+
+ .filter(Boolean);
+
+ const keywords = document
+
+ .getElementById("knowledge-keywords")
+
+ .value.split(",")
+
+ .map((item) => item.trim())
+
+ .filter(Boolean);
+
+ const memo = document.getElementById("knowledge-memo").value.trim();
+
+ const imageInput = document.getElementById("knowledge-image");
+
+ const status = document.getElementById("knowledge-entry-status");
+
+ const submitButton = event.target.querySelector('button[type="submit"]');
+
+
+
+ if (!name || !memo) return;
+
+
+
+ submitButton.disabled = true;
+
+ status.textContent = "기준 DB에 저장하는 중입니다.";
+
+ try {
+
+ const image = await readKnowledgeImage(imageInput.files[0]);
+
+ const payload = await apiJson("/api/knowledge/manual", {
+
+ method: "POST",
+
+ body: JSON.stringify({ name, type, aliases, keywords, memo, image }),
+
+ });
+
+ applyBootstrap(payload);
+
+ event.target.reset();
+
+ updateKnowledgeImageName();
+
+ status.textContent = image ? "참조 이미지와 이미지 지문을 저장했습니다." : "기준 DB에 저장했습니다.";
+
+ renderAll();
+
+ } catch (errorValue) {
+
+ status.textContent = errorValue.message;
+
+ } finally {
+
+ submitButton.disabled = false;
+
+ }
+
+}
+
+
diff --git a/web/operator-gui/app.js b/web/operator-gui/app.js
deleted file mode 100644
index 088b77d..0000000
--- a/web/operator-gui/app.js
+++ /dev/null
@@ -1,5428 +0,0 @@
-const {
- riskLabels,
- decisionLabels,
- providerLabels,
- sourceLabels,
- providerStatusLabels,
- candidateStatusLabels,
- evidenceStatusLabels,
- knowledgeStatusLabels,
- provenanceLabels,
- knowledgeTypeLabels,
-} = window.OperatorLabels;
-
-const retiredProviderIds = new Set();
-const operatorSearchProviders = [
- { id: "naver", label: providerLabels.naver },
- { id: "google_search", label: providerLabels.google_search },
-];
-const submissions = [];
-
-
-
-const providers = [];
-
-
-
-const knowledgeEntries = [];
-
-
-
-const searchCoverage = {};
-
-let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과
-
-const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
-let editingKnowledgeEntryId = "";
-
-let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds
-
-const DEFAULT_COVERAGE_THRESHOLDS = Object.freeze({
-
- coverageGoodRate: 70,
-
- coverageWarnRate: 40,
-
- queryGoodRate: 70,
-
- queryWarnRate: 40,
-
-});
-
-const coverageThresholds = { ...DEFAULT_COVERAGE_THRESHOLDS };
-
-
-
-const collectionCandidates = [];
-
-
-
-const corrections = [];
-
-
-
-const auditEvents = [];
-
-
-
-const decisionMemoPresets = [];
-
-
-
-const state = {
-
- currentView: "queue",
-
- workbenchTab: "evidence",
-
- knowledgeTab: "collect",
-
- currentCollectionQuery: "",
-
- currentCollectionProvider: "",
-
- selectedCaseId: submissions[0]?.id || "",
-
- apiError: "",
-
- submissionQueue: null,
-
- importStatus: {
-
- tone: "idle",
-
- message: "로컬 제출 저장소 변경 대기 중",
-
- },
-
- filters: {
-
- risk: "all",
-
- source: "all",
-
- decision: "all",
-
- sort: "risk",
-
- query: "",
-
- },
-
-};
-
-
-
-const viewNames = new Set(["queue", "workbench", "knowledge", "providers", "stats", "audit"]);
-
-
-
-// Bearer token for a server started with COPYRIGHTER_AUTH_TOKEN. Held in memory
-// only (never localStorage/sessionStorage): the first authenticated request makes
-// the server mint an HttpOnly session cookie, which is what subsequently
-// authorizes
/media requests that cannot carry a header.
-
-let authToken = "";
-
-let pendingAuthPrompt = null;
-
-
-
-function promptForAuthToken(message = "") {
-
- const gate = document.getElementById("auth-gate");
-
- const form = document.getElementById("auth-gate-form");
-
- const input = document.getElementById("auth-gate-input");
-
- const error = document.getElementById("auth-gate-error");
-
- if (!gate || !form || !input) {
-
- return Promise.resolve("");
-
- }
-
- // Collapse concurrent 401s onto one prompt so a burst of parallel requests
- // cannot stack multiple dialogs.
-
- if (pendingAuthPrompt) {
-
- return pendingAuthPrompt;
-
- }
-
- if (error) {
-
- error.hidden = !message;
-
- error.textContent = message || "";
-
- }
-
- gate.hidden = false;
-
- input.value = "";
-
- input.focus();
-
- pendingAuthPrompt = new Promise((resolve) => {
-
- const onSubmit = (event) => {
-
- event.preventDefault();
-
- const value = input.value.trim();
-
- if (!value) {
-
- return;
-
- }
-
- form.removeEventListener("submit", onSubmit);
-
- gate.hidden = true;
-
- input.value = "";
-
- pendingAuthPrompt = null;
-
- resolve(value);
-
- };
-
- form.addEventListener("submit", onSubmit);
-
- });
-
- return pendingAuthPrompt;
-
-}
-
-
-
-function authHeaders(extra) {
-
- const headers = { "Content-Type": "application/json", ...(extra || {}) };
-
- if (authToken) {
-
- headers.Authorization = `Bearer ${authToken}`;
-
- }
-
- return headers;
-
-}
-
-
-
-async function apiJson(path, options = {}, allowAuthRetry = true) {
-
- const response = await fetch(path, {
-
- ...options,
-
- headers: authHeaders(options.headers),
-
- });
-
- const text = await response.text();
-
- let payload = null;
-
- if (text) {
-
- try {
-
- payload = JSON.parse(text);
-
- } catch (error) {
-
- payload = null;
-
- }
-
- }
-
- if (response.status === 401 && allowAuthRetry) {
-
- // Ask for the token, then replay this request once. A second 401 falls
- // through to the normal error path instead of looping.
-
- const token = await promptForAuthToken(
-
- authToken ? "토큰이 올바르지 않습니다. 다시 입력하세요." : "",
-
- );
-
- if (token) {
-
- authToken = token;
-
- return apiJson(path, options, false);
-
- }
-
- }
-
- if (!response.ok) {
-
- const message = (payload && payload.error) || `API 요청 실패: ${response.status}`;
-
- throw new Error(message);
-
- }
-
- return payload;
-
-}
-
-
-
-function replaceCollection(target, next) {
-
- target.splice(0, target.length, ...(next || []));
-
-}
-
-
-
-function clearRuntimeData() {
-
- replaceCollection(submissions, []);
-
- replaceCollection(providers, []);
-
- replaceCollection(knowledgeEntries, []);
-
- replaceCollection(collectionCandidates, []);
-
- replaceCollection(corrections, []);
-
- replaceCollection(auditEvents, []);
-
- Object.assign(coverageThresholds, DEFAULT_COVERAGE_THRESHOLDS);
-
- Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
-
- state.submissionQueue = null;
-
- state.selectedCaseId = "";
-
-}
-
-
-
-function applyBootstrap(payload) {
-
- // Any successful payload proves the API is reachable, so clear a stale error
- // banner here (not only in refreshFromApi) — actions that applyBootstrap
- // directly, e.g. promote/dismiss, otherwise leave the old error visible.
- state.apiError = "";
-
- replaceCollection(submissions, payload.submissions);
-
- replaceCollection(providers, payload.providers);
-
- replaceCollection(knowledgeEntries, payload.knowledgeEntries);
-
- state.submissionQueue = payload.submissionQueue || null;
-
- Object.assign(
-
- coverageThresholds,
-
- normalizeCoverageThresholds(payload.coverageThresholds || (payload.searchCoverage && payload.searchCoverage.coverageThresholds)),
-
- );
-
- Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
-
- Object.assign(searchCoverage, payload.searchCoverage || {});
-
- replaceCollection(collectionCandidates, payload.collectionCandidates);
-
- replaceCollection(corrections, payload.corrections);
-
- replaceCollection(auditEvents, payload.auditEvents);
-
- replaceCollection(decisionMemoPresets, payload.decisionMemoPresets || []);
-
- if (!submissions.some((item) => item.id === state.selectedCaseId)) {
-
- state.selectedCaseId = submissions[0]?.id || "";
-
- }
-
-}
-
-
-
-async function refreshFromApi() {
-
- const payload = await apiJson("/api/bootstrap");
-
- state.apiError = "";
-
- applyBootstrap(payload);
-
- renderAll();
-
-}
-
-
-
-function setImportStatus(message, tone = "idle") {
-
- state.importStatus = { message, tone };
-
- renderImportStatus();
-
-}
-
-
-
-function coveragePercent(numerator, denominator) {
-
- if (!denominator) return 0;
-
- return Math.round((numerator / denominator) * 1000) / 10;
-
-}
-
-
-
-function normalizeCoverageThresholds(next = {}) {
-
- const raw = next || {};
-
- const normalized = {};
-
- const intValue = (value) => {
-
- const parsed = Number(value);
-
- if (!Number.isFinite(parsed)) return null;
-
- return Math.max(0, Math.min(100, Math.round(parsed)));
-
- };
-
-
-
- normalized.coverageGoodRate = intValue(raw.coverageGoodRate);
-
- if (!Number.isFinite(normalized.coverageGoodRate)) {
-
- normalized.coverageGoodRate = DEFAULT_COVERAGE_THRESHOLDS.coverageGoodRate;
-
- }
-
-
-
- normalized.coverageWarnRate = intValue(raw.coverageWarnRate);
-
- if (!Number.isFinite(normalized.coverageWarnRate)) {
-
- normalized.coverageWarnRate = DEFAULT_COVERAGE_THRESHOLDS.coverageWarnRate;
-
- }
-
- normalized.coverageWarnRate = Math.min(normalized.coverageWarnRate, normalized.coverageGoodRate);
-
-
-
- normalized.queryGoodRate = intValue(raw.queryGoodRate);
-
- if (!Number.isFinite(normalized.queryGoodRate)) {
-
- normalized.queryGoodRate = DEFAULT_COVERAGE_THRESHOLDS.queryGoodRate;
-
- }
-
-
-
- normalized.queryWarnRate = intValue(raw.queryWarnRate);
-
- if (!Number.isFinite(normalized.queryWarnRate)) {
-
- normalized.queryWarnRate = DEFAULT_COVERAGE_THRESHOLDS.queryWarnRate;
-
- }
-
- normalized.queryWarnRate = Math.min(normalized.queryWarnRate, normalized.queryGoodRate);
-
-
-
- return normalized;
-
-}
-
-
-
-function coverageTone(rate, kind = "coverage") {
-
- const config = coverageThresholds;
-
- const goodRate = config[`${kind}GoodRate`];
-
- const warnRate = config[`${kind}WarnRate`];
-
- if (!Number.isFinite(goodRate) || !Number.isFinite(warnRate)) return "bad";
-
- if (rate >= goodRate) return "ok";
-
- if (rate >= warnRate) return "warn";
-
- return "bad";
-
-}
-
-
-
-function coverageToneLabelConfig(kind = "coverage") {
-
- const config = coverageThresholds;
-
- const goodRate = Number.isFinite(config[`${kind}GoodRate`])
-
- ? config[`${kind}GoodRate`]
-
- : DEFAULT_COVERAGE_THRESHOLDS[`${kind}GoodRate`];
-
- const warnRate = Number.isFinite(config[`${kind}WarnRate`])
-
- ? config[`${kind}WarnRate`]
-
- : DEFAULT_COVERAGE_THRESHOLDS[`${kind}WarnRate`];
-
- return `양호: ${goodRate}% 이상, 주의: ${warnRate}%~${Math.max(0, goodRate - 1)}%, 위험: ${Math.max(0, warnRate - 1)}% 미만`;
-
-}
-
-
-
-function clampPercent(value) {
-
- if (!Number.isFinite(value)) return 0;
-
- return Math.max(0, Math.min(100, value));
-
-}
-
-
-
-function coverageTabButton({ filter, label, value, tone = "ok", title = "" }) {
-
- const active = state.filters.source === filter || (filter === "all" && state.filters.source === "all");
-
- return `
-
-
-
- `;
-
-}
-
-
-
-function renderCoverageTabs() {
-
- const target = document.getElementById("coverage-tabs");
-
- if (!target) return;
-
-
-
- const summaries = searchCoverage.submissions || {};
-
- const queries = searchCoverage.queries || {};
-
- const providers = (searchCoverage.providers || []).filter((provider) => !retiredProviderIds.has(provider.id));
-
- const total = Number(summaries.total || submissions.length || 0);
-
- const coverageRate = coveragePercent(summaries.coverageSubmissions || 0, total);
-
- const coverageStatus = coverageTone(coverageRate, "coverage");
-
- const failedQueries = Number(queries.failed || 0);
-
- const providerTabs = [...providers]
-
- .sort((left, right) => (right.querySubmissions || 0) - (left.querySubmissions || 0))
-
- .map((provider) => {
-
- const filter = provider.id && provider.id.includes("google") ? "google" : provider.id || "all";
-
- const queryCount = Number(provider.queryEntries || 0);
-
- const evidenceCount = Number(provider.evidenceSubmissions || 0);
-
- return coverageTabButton({
-
- filter,
-
- label: provider.name || provider.id || "provider",
-
- value: `${queryCount} / ${evidenceCount}`,
-
- tone: evidenceCount ? "ok" : queryCount ? "warn" : "idle",
-
- title: `쿼리 ${queryCount}, 근거 보유 케이스 ${evidenceCount}`,
-
- });
-
- })
-
- .join("");
-
-
-
- target.innerHTML = `
-
- ${coverageTabButton({ filter: "all", label: "전체", value: `${total}` })}
-
- ${coverageTabButton({
-
- filter: "covered",
-
- label: "근거 보유",
-
- value: `${summaries.coverageSubmissions || 0}/${total}`,
-
- tone: coverageStatus,
-
- title: coverageToneLabelConfig("coverage"),
-
- })}
-
- ${providerTabs || coverageTabButton({ filter: "all", label: "외부 검색 tool", value: "0" })}
-
- ${coverageTabButton({ filter: "failure", label: "실패", value: `${failedQueries}`, tone: failedQueries ? "bad" : "idle" })}
-
- `;
-
-}
-
-
-
-function applyCoverageFilter(filter) {
-
- state.filters.source = filter || "all";
-
- const sourceSelect = document.getElementById("source-filter");
-
- if (sourceSelect) {
- sourceSelect.value = [...sourceSelect.options].some((option) => option.value === state.filters.source) ? state.filters.source : "all";
- }
-
- renderCoverageTabs();
-
- renderQueue();
-
-}
-
-
-
-function showApiError(message) {
-
- state.apiError = message;
-
- const target = document.getElementById("queue-health");
-
- if (target) target.textContent = message;
-
-}
-
-
-
-function escapeHtml(value) {
-
- return String(value)
-
- .replaceAll("&", "&")
-
- .replaceAll("<", "<")
-
- .replaceAll(">", ">")
-
- .replaceAll('"', """)
-
- .replaceAll("'", "'");
-
-}
-
-
-
-function safeUrl(value) {
-
- // Defense-in-depth for URLs derived from external search results: only allow
-
- // absolute http(s) or same-origin paths into href/src (blocks javascript:,
-
- // data:, etc.). The server also normalizes these server-side.
-
- const url = String(value || "").trim();
-
- // Same-origin path, but reject protocol-relative ("//host") and backslash
- // ("/\\host") forms that browsers normalize to an external host.
- const isSameOriginPath = url.startsWith("/") && !url.startsWith("//") && !url.startsWith("/\\");
-
- if (/^https?:\/\//i.test(url) || isSameOriginPath) {
-
- return url;
-
- }
-
- return "#";
-
-}
-
-
-
-function formatReason(reason) {
- const text = String(reason || "");
- if (!text) return "";
-
- if (text === "Naver search result found") return "네이버 검색 결과 발견";
- if (text === "Naver search returned no results") return "네이버 검색 결과 없음";
- if (text === "Naver blog search result found") return "네이버 블로그 검색 결과 발견";
- if (text === "Naver blog search returned no results") return "네이버 블로그 검색 결과 없음";
- if (text === "Naver web search result found") return "네이버 웹문서 검색 결과 발견";
- if (text === "Naver web search returned no results") return "네이버 웹문서 검색 결과 없음";
- if (text === "Google custom image search result found") return "구글 이미지 검색 결과 발견";
- if (text === "Google custom image search returned no results") return "구글 이미지 검색 결과 없음";
- if (text === "Google custom web search result found") return "구글 웹 검색 결과 발견";
- if (text === "Google custom web search returned no results") return "구글 웹 검색 결과 없음";
- if (text.startsWith("Google face crop web evidence: ")) {
- return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
- }
- if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
- if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
- if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;
- if (text === "LLM summary has no source evidence") return "내부 요약에 연결 근거 없음";
- if (text === "LLM summary generated") return "내부 요약 생성";
- if (text.startsWith("LLM assistance failed")) return text.replace("LLM assistance failed", "내부 요약 실패");
- if (text.includes("Google Web Detection timeout")) return "구글 이미지 감지 시간 초과";
- if (text === "local submission") return "로컬 제출 이미지";
- if (text.startsWith("naver.example / rank ")) return text.replace("naver.example / rank ", "네이버 예시 / 순위 ");
- if (text.startsWith("google.example / page")) return "구글 예시 / 페이지";
- if (text.startsWith("prior rejection / ")) return text.replace("prior rejection / ", "이전 반려 / ");
-
- return text
- .replaceAll("Google Web Detection", "구글 이미지 감지")
- .replaceAll("Google", "구글")
- .replaceAll("Naver", "네이버")
- .replaceAll("google", "구글")
- .replaceAll("naver", "네이버")
- .replaceAll("internal", "내부")
- .replaceAll("local", "로컬")
- .replaceAll("submission", "제출")
- .replaceAll("page", "페이지")
- .replaceAll("weak", "약함")
- .replaceAll("LLM", "내부 요약")
- .replaceAll("confidence", "신뢰도")
- .replaceAll("sourceEvidenceIds", "근거 ID")
- .replaceAll("rank", "순위");
-}
-
-function formatEvidenceTitle(evidence) {
-
- return formatReason(evidence.title || evidence.reason || "");
-
-}
-
-
-
-function formatProviderStatus(status) {
-
- return providerStatusLabels[status] || status;
-
-}
-
-
-function formatProviderName(provider) {
- return providerLabels[provider.id] || provider.name || provider.id;
-}
-
-
-
-function formatQueueProviderStatus(provider, status) {
- const providerNames = {
- internal: "내부",
- naver: "네이버",
- naver_blog: "블로그",
- naver_web: "웹문서",
- google: "구글",
- google_search: "구글",
- llm: "요약",
- };
-
- const statusNames = {
- ok: "정상",
- covered: "근거 있음",
- empty: "없음",
- failed: "실패",
- disabled: "중지",
- skipped: "미실행",
- pending: "미실행",
- not_run: "미실행",
- };
-
- return `${providerNames[provider] || providerLabels[provider] || provider} ${statusNames[status] || formatProviderStatus(status)}`;
-}
-
-function visibleProviderStateEntries(providerState) {
-
- return Object.entries(providerState || {}).filter(([provider]) => !retiredProviderIds.has(provider));
-
-}
-
-
-
-function renderOperatorSearchProviderOptions() {
- const optionHtml = operatorSearchProviders
- .map((provider) => {
- const runtime = providers.find((item) => item.id === provider.id);
- const unavailable = Boolean(runtime) && !runtime.enabled;
- const label = unavailable ? `${provider.label} (비활성)` : provider.label;
- const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : "";
- return ``;
- })
- .join("");
-
- ["manual-query-provider", "collection-provider"].forEach((elementId) => {
- const select = document.getElementById(elementId);
- if (!select) return;
- const current = select.value;
- select.innerHTML = optionHtml;
- select.value = operatorSearchProviders.some((provider) => provider.id === current) ? current : operatorSearchProviders[0].id;
- if (select.selectedOptions[0] && select.selectedOptions[0].disabled) {
- select.value = operatorSearchProviders[0].id;
- }
- });
-}
-
-
-
-function formatCandidateStatus(status) {
-
- return candidateStatusLabels[status] || status;
-
-}
-
-
-
-function formatCandidateSourceType(sourceType) {
-
- const labels = {
-
- search_result_image: "검색 이미지 결과",
-
- provider_page_image: "페이지 대표 이미지",
-
- html_page_image: "페이지 대표 이미지",
-
- };
-
- return labels[sourceType] || sourceType || "검색 이미지 결과";
-
-}
-
-
-
-function formatQueryStatus(status) {
- return window.OperatorSearch.formatQueryStatus(status);
-}
-
-
-function formatQueryStrategy(strategy) {
- return window.OperatorSearch.formatQueryStrategy(strategy);
-}
-
-
-function formatEvidenceStatus(status) {
-
- return evidenceStatusLabels[normalizeEvidenceOperatorStatus(status)] || status || "자동 판단";
-
-}
-
-
-
-function normalizeEvidenceOperatorStatus(status) {
-
- if (status === "used_for_judgment") return "used_for_judgment";
-
- const ignoredStatuses = new Set();
-
- ignoredStatuses.add("irrelevant");
-
- ignoredStatuses.add("false_positive");
-
- ignoredStatuses.add("ignored");
-
- if (ignoredStatuses.has(status)) return "ignored";
-
- return status || "";
-
-}
-
-
-
-function evidenceStatusPayload(status) {
-
- return status === "ignored" ? "false_positive" : status;
-
-}
-
-
-
-function formatKnowledgeStatus(status) {
-
- return knowledgeStatusLabels[status || "confirmed"] || status;
-
-}
-
-
-
-function formatKnowledgeType(type) {
-
- return knowledgeTypeLabels[type] || type;
-
-}
-
-
-
-function formatProvenance(provenance) {
-
- return provenanceLabels[provenance] || provenance;
-
-}
-
-
-
-function formatMatchType(matchType) {
- const labels = {
- full: "동일 이미지",
- partial: "부분 일치",
- page: "포함 페이지",
- visual: "유사 이미지",
- weak_label: "약한 라벨",
- entity: "엔티티",
- google_best_guess: "구글 추정 라벨",
- google_face_crop_page: "얼굴 영역 웹페이지",
- google_face_crop_entity: "얼굴 영역 엔티티",
- empty: "결과 없음",
- };
-
- labels.search_result_image = "검색 결과 이미지 지목";
- labels.search_result_page_image = "검색 결과 페이지 이미지 지목";
- return labels[matchType] || matchType;
-}
-
-
-
-function formatFactLabel(label) {
- const labels = {
- size: "크기",
- format: "형식",
- submitted: "제출",
- analysis: "분석",
- };
-
- return labels[label] || formatReason(label);
-}
-
-
-
-function formatDomain(domain) {
- if (!domain) return "내부";
- return formatReason(domain);
-}
-
-
-function formatQueueTimestamp(value) {
- return value || "-";
-}
-
-
-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 `
-
-
- 근거 보강 추천
- 추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.
-
-
- ${reasons.map((reason) => `- ${escapeHtml(reason)}
`).join("")}
-
-
- ${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;
-
- });
-
-}
-
-
-
-function renderQueue() {
-
- const rows = filteredSubmissions();
-
- const queueLabel = state.submissionQueue?.label || "";
-
- const queueMeta = queueLabel ? ` (${escapeHtml(queueLabel)})` : "";
-
- const body = document.getElementById("queue-body");
-
-
-
- document.getElementById("queue-health").innerHTML = state.apiError
-
- ? `
-
-
-
- ${escapeHtml(state.apiError)}
-
- `
-
- : `
-
-
-
- ${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta}
-
- `;
-
-
-
- if (!rows.length) {
-
- body.innerHTML = `
-
-
-
- |
-
- 현재 필터에 맞는 심사 건이 없습니다.
-
- |
-
-
-
- `;
-
- return;
-
- }
-
-
-
- body.innerHTML = rows
-
- .map((submission) => {
-
- const providerHtml = visibleProviderStateEntries(submission.providerState)
-
- .map(
-
- ([provider, status]) => {
-
- const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
-
- return `${escapeHtml(formatQueueProviderStatus(provider, status))}`;
-
- },
-
- )
-
- .join("");
-
- const reasonHtml = submission.reasons
-
- .slice(0, 2)
-
- .map((reason) => `${escapeHtml(formatReason(reason))}`)
-
- .join("");
-
- const submittedTime = formatQueueTimestamp(submission.submittedAt);
-
- return `
-
-
-
- |
-
- }) |
-
-
-
-
-
-
-
- ${duplicateBadgeHtml(submission)}
-
-
-
- |
-
-
-
-
-
- ${riskLabels[submission.riskBand]} ${submission.riskScore}
-
-
-
- |
-
- ${reasonHtml} |
-
- ${providerHtml} |
-
- ${escapeHtml(submission.applicantStatus)} |
-
- ${decisionLabels[submission.decisionStatus]} |
-
-
-
-
-
- ${escapeHtml(submittedTime)}
-
-
-
- |
-
-
-
- `;
-
- })
-
- .join("");
-
-}
-
-
-
-function renderImportStatus() {
-
- const target = document.getElementById("submission-import-status");
-
- if (!target) return;
-
- const tone = state.importStatus.tone === "bad" ? "bad" : state.importStatus.tone === "warn" ? "warn" : "ok";
-
- target.innerHTML = `
-
-
-
- ${escapeHtml(state.importStatus.message)}
-
- `;
-
-}
-
-
-
-function renderNoSelectedCase() {
- document.getElementById("case-title").textContent = "선택된 제출 이미지가 없습니다";
- document.getElementById("case-image").removeAttribute("src");
- document.getElementById("case-image").alt = "";
- document.getElementById("image-derivative-note").textContent = "API 연결 후 제출 이미지를 불러오면 이 영역에 분석 대상이 표시됩니다.";
- document.getElementById("case-score").className = "score-pill risk-pending";
- document.getElementById("case-score").textContent = "대기";
- document.getElementById("floating-case-score").className = "score-pill risk-pending";
- document.getElementById("floating-case-score").textContent = "대기";
- document.getElementById("file-facts").innerHTML = "";
- document.getElementById("similar-strip").innerHTML = "";
- document.getElementById("face-crop-strip").innerHTML = "";
- activeRerunAddedIds = new Set();
- document.getElementById("rerun-diff-summary").innerHTML = "";
- document.getElementById("case-reasons").innerHTML = `표시할 케이스가 없습니다.
`;
- document.getElementById("evidence-groups").innerHTML = `API 연결 실패 또는 제출 이미지 없음 상태입니다.
`;
- document.getElementById("recommendation-box").innerHTML = `실제 API 데이터가 로드될 때까지 판정하지 않습니다.`;
- document.getElementById("derived-preview").innerHTML = `자동 후보 생성 없음`;
- // Clear the evidence-gap recommendations too: they belong to the case that was
- // just deselected, and leaving them rendered invited running suggested queries
- // against nothing.
- const nextActions = document.getElementById("evidence-next-actions");
- if (nextActions) nextActions.innerHTML = "";
- const llmDraft = document.getElementById("llm-draft");
- if (llmDraft) {
- llmDraft.hidden = true;
- llmDraft.innerHTML = "";
- }
-}
-
-
-function renderCaseReview() {
-
- const submission = getSelectedCase();
-
- if (!submission) {
-
- renderNoSelectedCase();
-
- return;
-
- }
-
- renderLlmDraft(submission);
-
- document.getElementById("case-title").textContent = `${submission.id} · ${submission.title}`;
-
- const caseImageElement = document.getElementById("case-image");
-
- caseImageElement.src = safeUrl(submission.asset);
-
- caseImageElement.alt = `${submission.id} 제출 이미지`;
-
- // Reset the view controls (rotate / fit) so a new case starts unrotated.
- caseImageElement.dataset.rotation = "0";
-
- caseImageElement.style.transform = "";
-
- caseImageElement.style.objectFit = "";
-
- document.getElementById("image-derivative-note").textContent = submission.derivativeNote;
-
- document.getElementById("case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
-
- document.getElementById("case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
-
- document.getElementById("floating-case-score").className = `score-pill ${riskClass(submission.riskBand)}`;
-
- document.getElementById("floating-case-score").textContent = `${riskLabels[submission.riskBand]} ${submission.riskScore}`;
-
- document.getElementById("file-facts").innerHTML = Object.entries(submission.fileFacts)
-
- .map(([label, value]) => `${escapeHtml(formatFactLabel(label))}${escapeHtml(value)}
`)
-
- .join("");
-
-
-
- document.getElementById("similar-strip").innerHTML = submission.similar
-
- .map(
-
- (item) => `
-
-
-
-
)})
-
-
${escapeHtml(formatReason(item.label))}
-
-
-
- `,
-
- )
-
- .join("");
-
- document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
- .map(
- (crop) => `
-
-
- 얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다
-
- `,
- )
- .join("");
-
- activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
- document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff
- ? renderRerunDiffSummary(submission.lastRerunDiff)
- : "";
-
- document.getElementById("case-reasons").innerHTML = renderEvidenceSummary(submission);
-
- document.getElementById("evidence-next-actions").innerHTML = renderEvidenceNextActions(submission);
-
-
- document.getElementById("evidence-groups").innerHTML = renderEvidenceGroups(submission);
-
- document.getElementById("recommendation-box").innerHTML = `
-
- ${riskLabels[submission.riskBand]}
-
- ${escapeHtml(submission.recommendation.label)}
-
- ${escapeHtml(formatReason(submission.recommendation.detail))}
-
- 현재 운영 결정: ${decisionLabels[submission.decisionStatus]}
-
- `;
-
- document.getElementById("derived-preview").innerHTML = `
-
- ${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}
-
- ${escapeHtml(submission.derivedPreview.entryName)}
-
- ${escapeHtml(submission.derivedPreview.effect)}
-
- `;
-
-}
-
-
-
-function evidenceGroupDefinitions() {
- return [
- { id: "watchlist", label: "주의 후보 근거", description: "보류/반려 이력에서 생성된 주의 후보 또는 유사 이미지입니다.", tone: "strong" },
- { id: "face_web", label: "얼굴 영역 웹 근거", description: "얼굴 영역만으로 찾은 웹 검색 결과입니다. 동일 인물 판정은 아닙니다.", tone: "search" },
- { id: "full", label: "동일 이미지", description: "원본 이미지 직접 매칭", tone: "strong" },
- { id: "partial", label: "부분 매칭", description: "일부 영역 또는 파생 이미지 매칭", tone: "strong" },
- { id: "page", label: "출처 페이지", description: "같은 이미지가 포함된 웹페이지", tone: "strong" },
- { id: "naver", label: "네이버 검색", description: "텍스트 기반 이미지 검색 결과", tone: "search" },
- { id: "face", label: "얼굴/인물 감지", description: "동일 인물 판정은 아니며 얼굴 또는 사람 존재만 표시합니다.", tone: "local" },
- { id: "provenance", label: "출처 표기", description: "파일에 들어 있던 권리 표기와 화면 캡처 신호입니다. 자기신고 정보이며 점수에 반영하지 않습니다.", tone: "local" },
- { id: "visual", label: "유사 이미지", description: "시각적으로 유사한 참고 결과", tone: "weak" },
- { id: "weak", label: "약한 라벨", description: "점수에 직접 반영하지 않는 힌트", tone: "weak" },
- { id: "llm", label: "내부 요약", description: "출처 연결 요약 메모", tone: "note" },
- { id: "internal", label: "내부 지문", description: "로컬 지문 기반 내부 처리 기록", tone: "local" },
- { id: "failure", label: "실패/건너뜀", description: "외부 검색 도구 실패 또는 정책상 스킵", tone: "failure" },
- ];
-}
-
-
-function evidenceGroupFor(evidence) {
-
- if (evidence.knowledgeEntryStatus === "watchlist") return "watchlist";
-
- if (evidence.faceCropSearch) return "face_web";
-
- if (evidence.source === "google") {
-
- if (evidence.matchType === "full") return "full";
-
- if (evidence.matchType === "partial") return "partial";
-
- if (evidence.matchType === "page") return "page";
-
- if (evidence.matchType === "visual") return "visual";
-
- if (evidence.matchType === "weak_label" || evidence.status === "weak") return "weak";
-
- return "visual";
-
- }
-
- if (evidence.source === "naver") return "naver";
-
- if (evidence.source === "face") return "face";
-
- if (evidence.source === "provenance") return "provenance";
-
- if (evidence.source === "llm") return "llm";
-
- if (evidence.source === "failure") return "failure";
-
- return "internal";
-
-}
-
-
-
-function groupedEvidence(evidence) {
-
- return evidence.reduce((collection, item) => {
-
- const group = evidenceGroupFor(item);
-
- if (!collection[group]) collection[group] = [];
-
- collection[group].push(item);
-
- return collection;
-
- }, {});
-
-}
-
-
-
-function evidencePriorityScore(evidence) {
-
- const groupPriority = {
-
- full: 100,
-
- watchlist: 95,
-
- partial: 85,
-
- page: 75,
-
- naver: 65,
-
- face_web: 58,
-
- face: 55,
-
- visual: 35,
-
- weak: 10,
-
- llm: 8,
-
- internal: 6,
-
- failure: 4,
-
- };
-
- return (groupPriority[evidenceGroupFor(evidence)] || 0) + Number(evidence.confidence || 0);
-
-}
-
-
-
-function sortEvidenceItems(items) {
-
- return [...items].sort((left, right) => evidencePriorityScore(right) - evidencePriorityScore(left));
-
-}
-
-
-
-function renderRerunDiffSummary(diff) {
- const scoreBefore = Number(diff.scoreBefore || 0);
- const scoreAfter = Number(diff.scoreAfter || 0);
- const delta = scoreAfter - scoreBefore;
- const direction = delta > 0 ? `상승 ${delta}` : delta < 0 ? `하락 ${Math.abs(delta)}` : "변동 없음";
- const removedSummaries = diff.removedSummaries || [];
- const removedBlock = removedSummaries.length
- ? `
-
- 이번 재분석에서 제거됨 · ${removedSummaries.length}개
-
- ${removedSummaries
- .map((item) => `- ${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}
`)
- .join("")}
-
-
- `
- : "";
- return `
-
- 재분석 ${escapeHtml(diff.at || "")}
- 점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})
- 신규 증거 ${(diff.addedEvidenceIds || []).length}개
- ${removedBlock}
-
- `;
-}
-
-
-function renderEvidenceSummary(submission) {
- const groups = groupedEvidence(submission.evidence);
- const count = (group) => (groups[group] || []).length;
- const directCount = count("full") + count("partial") + count("page");
- const searchCount = directCount + count("naver");
- const primaryJudgment = directCount
- ? "직접 매칭 우선 확인"
- : count("visual")
- ? "유사 이미지 참고 확인"
- : count("face")
- ? "인물 신호만 확인됨"
- : "증거 부족";
- const summaryCards = [
- ["동일", count("full")],
- ["부분", count("partial")],
- ["페이지", count("page")],
- ["유사", count("visual")],
- ["네이버", count("naver")],
- ["인물", count("face")],
- ];
-
- return `
-
-
-
${riskLabels[submission.riskBand]} ${submission.riskScore}
-
- ${escapeHtml(primaryJudgment)}
- ${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거
-
-
-
- ${summaryCards
- .map(
- ([label, value]) => `
-
- ${escapeHtml(label)}
- ${value}
-
- `,
- )
- .join("")}
-
-
- `;
-}
-
-
-
-function renderEvidenceGroups(submission) {
- if (!submission.evidence.length) {
- return `아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.
`;
- }
-
- const groups = groupedEvidence(submission.evidence);
- return evidenceGroupDefinitions()
- .filter((group) => groups[group.id]?.length)
- .map((group) => renderEvidenceGroup(group, sortEvidenceItems(groups[group.id])))
- .join("");
-}
-
-
-function renderEvidenceGroup(group, items) {
-
- const topItems = items.slice(0, 3);
-
- const detailItems = items.slice(3);
-
- return `
-
-
-
-
-
-
-
-
${escapeHtml(group.label)}
-
- ${escapeHtml(group.description)}
-
-
-
-
${items.length}개
-
-
-
-
-
- ${topItems.map(renderEvidenceRow).join("")}
-
-
-
- ${
-
- detailItems.length
-
- ? `
-
-
-
- 자세히 보기 · ${detailItems.length}개
-
-
-
- ${detailItems.map(renderEvidenceRow).join("")}
-
-
-
-
-
- `
-
- : ""
-
- }
-
-
-
- `;
-
-}
-
-
-
-function renderEvidenceRow(evidence) {
- const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
- const newChip = isNewFromRerun ? `신규` : "";
- const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
- const sourceIds = sourceEvidenceIds.length
- ? `근거 ID: ${sourceEvidenceIds.join(", ")}`
- : "근거 ID 없음";
- const contribution = evidence.contributed ? "점수 반영" : "참고";
- const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
- const operatorStatus = normalizedOperatorStatus
- ? `${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}`
- : "";
- const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
- const matchType = evidence.matchType ? `${escapeHtml(formatMatchType(evidence.matchType))}` : "";
- const pageTitle = evidence.pageTitle ? `${escapeHtml(evidence.pageTitle)}` : "";
- const faceCropMeta = evidence.faceCropSearch
- ? `얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}`
- : "";
- const watchlistMeta =
- evidence.knowledgeEntryStatus === "watchlist"
- ? `주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}`
- : "";
- const providerScore =
- evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `검색 도구 점수 ${escapeHtml(evidence.providerScore)}` : "";
- const similarity =
- evidence.similarity !== undefined && evidence.similarity !== ""
- ? `이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}`
- : "";
- const queryStrategy = evidence.queryStrategy ? `${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}` : "";
- const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
- const searchType = evidence.searchType ? `${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}` : "";
- const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
- const preview = renderEvidencePreview(evidence);
- const link = renderEvidenceLink(evidence);
- const statusButtons = [
- ["used_for_judgment", "사용"],
- ["ignored", "미사용"],
- ]
- .map(
- ([status, label]) => `
-
- `,
- )
- .join("");
-
- return `
-
- ${preview}
-
-
- ${escapeHtml(sourceLabels[evidence.source] || evidence.source)}
- ${newChip}
- ${escapeHtml(formatEvidenceTitle(evidence))}
-
-
- 신뢰도 ${escapeHtml(confidence)}
- ${escapeHtml(contribution)}
- ${operatorStatus}
- ${matchType}
- ${faceCropMeta}
- ${watchlistMeta}
- ${providerScore}
- ${similarity}
- ${queryStrategy}
- ${searchType}
- ${pageTitle}
- ${escapeHtml(evidence.query || "쿼리 없음")}
- ${escapeHtml(formatDomain(evidence.domain))}
- ${escapeHtml(evidence.retrievedAt)}
- ${escapeHtml(sourceIds)}
-
- ${link}
-
-
- ${statusButtons}
-
-
- `;
-}
-
-
-function renderEvidencePreview(evidence) {
-
- const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
-
- const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
-
- if (!previewUrl) return ``;
-
- return `
-
-
-
-
-
-
-
- `;
-
-}
-
-
-
-function renderEvidenceLink(evidence) {
-
- const url = evidence.url || evidence.imageUrl || "";
-
- if (!url) return "";
-
- const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
-
- return `
-
-
-
- ${escapeHtml(label)}
-
-
-
- `;
-
-}
-
-
-
-function renderEvidenceSearch() {
- const submission = getSelectedCase();
- const queryHistory = document.getElementById("query-history");
- const results = document.getElementById("search-results");
-
- if (!submission) {
- document.getElementById("query-case-label").textContent = "-";
- queryHistory.innerHTML = `표시할 케이스가 없습니다.
`;
- results.innerHTML = `API 연결 후 검색 근거를 확인할 수 있습니다.
`;
- return;
- }
-
- document.getElementById("query-case-label").textContent = submission.id;
- queryHistory.innerHTML = submission.queryHistory.length
- ? submission.queryHistory
- .map(
- (query) => `
-
-
- ${escapeHtml(providerLabels[query.provider] || query.provider)}
- ${escapeHtml(query.query)}
-
-
- ${escapeHtml(formatQueryStatus(query.status))}
- ${query.strategy ? `${escapeHtml(formatQueryStrategy(query.strategy))}` : ""}
- ${query.source ? `출처 ${escapeHtml(query.source)}` : ""}
- ${escapeHtml(query.timestamp)}
- ${query.count}건
-
-
-
- `,
- )
- .join("")
- : `아직 실행한 검색 쿼리가 없습니다.
`;
-
- activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
- const searchableEvidence = searchableEvidenceItems(submission);
- results.innerHTML = searchableEvidence.length
- ? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
- : `현재 케이스에는 검색 결과가 없습니다.
`;
-}
-
-
-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"
- ? `원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}미래 검출 ${escapeHtml(entry.contributionCount || 0)}건`
- : "";
- const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `${escapeHtml(entry.memo)}` : "";
- const memoBlock = memoInline ? "" : `${escapeHtml(entry.memo)}
`;
- const lifecycleActions =
- entryStatus === "watchlist"
- ? `
-
-
- `
- : entryStatus === "excluded"
- ? ""
- : entry.active
- ? ``
- : ``;
- const editForm =
- editingKnowledgeEntryId === entry.id
- ? `
-
- `
- : "";
- return `
-
- ${
- entry.imageAsset
- ? `
`
- : ``
- }
-
-
- ${escapeHtml(entry.name)}
-
-
-
- ${escapeHtml(formatProvenance(entry.provenance))}
- ${escapeHtml(formatKnowledgeStatus(entryStatus))}
-
-
- ${escapeHtml(formatKnowledgeType(entry.type))}
- 별칭: ${escapeHtml(aliases.join(", ") || "-")}
- 키워드: ${escapeHtml(keywords.join(", ") || "-")}
- 샘플 지문 ${(entry.sampleFingerprints || []).length}
- ${statusMeta}
- ${memoInline}
-
-
- ${memoBlock}
- ${editForm}
-
-
-
- ${lifecycleActions}
-
-
- `;
- })
- .join("")
- : `조건에 맞는 기준 항목이 없습니다.
`;
- 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 `
-
-
- ${
- candidate.imageAsset
- ? `
`
- : ``
- }
-
-
- ${escapeHtml(providerLabels[candidate.provider] || candidate.provider || "검색")}
- ${escapeHtml(candidate.title || candidate.query)}
-
-
- ${escapeHtml(candidate.query)}
- ${escapeHtml(formatCandidateSourceType(candidate.sourceCandidateType))}
- 순위 ${escapeHtml(candidate.rank || "-")}
- ${escapeHtml(formatCandidateStatus(candidate.status))}
-
- ${
- candidate.sourceUrl
- ? `
출처 열기`
- : ""
- }
-
-
-
-
-
-
- `;
- })
- .join("")
- : `검색어와 제공자를 선택하면 기준 DB 후보가 표시됩니다.
`;
-}
-
-
-
-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) => `
-
-
-
- 보정
- ${escapeHtml(correction.decisionId)} · ${escapeHtml(correction.submissionId)}
-
-
- ${escapeHtml(correction.previous)} -> ${escapeHtml(correction.current)}
- ${escapeHtml(correction.operator)}
- ${escapeHtml(correction.timestamp)}
-
-
${escapeHtml(correction.reason)}
-
- ${(correction.derivedEntries || [])
- .map(
- (entry) =>
- `${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}`,
- )
- .join("")}
-
-
-
-
- `,
- )
- .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 `환경 변수 확인됨: ${escapeHtml(requiredEnv.join(", "))}`;
- }
- return `누락된 환경 변수: ${escapeHtml(missing.join(", "))}`;
-}
-
-
-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 `
-
-
-
-
- ${escapeHtml(providerName)}
-
-
- ${provider.enabled ? "활성" : "중지"}
- ${escapeHtml(formatReason(provider.compliance))}
- 최근 성공: ${escapeHtml(formatReason(provider.lastSuccess))}
- 최근 실패: ${escapeHtml(formatReason(provider.lastFailure))}
-
-
${escapeHtml(formatReason(provider.boundary))}
- ${envStatus}
-
-
-
${provider.usage} / ${provider.quota == null ? "무제한" : provider.quota}${percent}%
-
-
-
-
-
-
-
- `;
- })
- .join("");
-}
-
-
-function renderAuditLog() {
-
- document.getElementById("audit-body").innerHTML = auditEvents
-
- .map(
-
- (event) => `
-
-
-
- | ${escapeHtml(event.timestamp)} |
-
- ${escapeHtml(event.actor)} |
-
- ${escapeHtml(formatReason(event.event))} |
-
- ${escapeHtml(formatReason(event.object))} |
-
- ${escapeHtml(formatReason(event.change))} |
-
-
-
- `,
-
- )
-
- .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 = `
-
-
-
- 외부 검색 tool ${visibleProviders.filter((provider) => provider.enabled).length}/${visibleProviders.length} 활성 · 실패 ${failed}
-
- `;
-
-}
-
-
-
-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 반영";
- });
-}
-
-
-function switchView(view, updateHash = true) {
-
- if (view === "case" || view === "evidence") view = "workbench";
-
- let knowledgeTabToApply = "";
-
- if (view === "corrections") {
-
- view = "knowledge";
-
- // Setting state alone left the visible panel on whatever knowledge tab was
- // last shown — the tab DOM is switched by switchKnowledgeTab, which must run
- // (applied at the end, after the view panels are toggled).
-
- knowledgeTabToApply = "corrections";
-
- }
-
- if (!viewNames.has(view)) return;
-
- state.currentView = view;
-
- // Stats are read on demand (a fresh cross-tab each open) rather than shipped
- // in every bootstrap payload.
-
- if (view === "stats") {
-
- void loadDecisionStats();
-
- }
-
- document.querySelectorAll(".nav-button").forEach((button) => {
-
- const active = button.dataset.view === view;
-
- button.classList.toggle("active", active);
-
- if (active) {
-
- button.setAttribute("aria-current", "page");
-
- } else {
-
- button.removeAttribute("aria-current");
-
- }
-
- });
-
-
-
- document.querySelectorAll("[data-view-panel]").forEach((panel) => {
-
- const active = panel.dataset.viewPanel === view;
-
- panel.classList.toggle("active", active);
-
- panel.hidden = !active;
-
- });
-
- if (knowledgeTabToApply) {
-
- switchKnowledgeTab(knowledgeTabToApply);
-
- }
-
- if (updateHash && window.location.hash.replace("#", "") !== view) {
-
- window.history.replaceState(null, "", `#${view}`);
-
- }
-
-}
-
-
-
-function switchKnowledgeTab(tab) {
-
- const nextTab = ["collect", "registered", "manual", "corrections"].includes(tab) ? tab : "collect";
-
- state.knowledgeTab = nextTab;
-
- document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
-
- const active = button.dataset.knowledgeTab === nextTab;
-
- button.classList.toggle("active", active);
-
- button.setAttribute("aria-selected", active ? "true" : "false");
-
- });
-
- document.querySelectorAll("[data-knowledge-panel]").forEach((panel) => {
-
- const active = panel.dataset.knowledgePanel === nextTab;
-
- panel.classList.toggle("active", active);
-
- panel.hidden = !active;
-
- });
-
-}
-
-
-
-function switchWorkbenchTab(tab) {
-
- const nextTab = ["evidence", "queries"].includes(tab) ? tab : "evidence";
-
- state.workbenchTab = nextTab;
-
- document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
-
- const active = button.dataset.workbenchTab === nextTab;
-
- button.classList.toggle("active", active);
-
- button.setAttribute("aria-selected", active ? "true" : "false");
-
- });
-
- document.querySelectorAll("[data-workbench-panel]").forEach((panel) => {
-
- const panelName = panel.dataset.workbenchPanel;
-
- const active = panelName === nextTab;
-
- panel.classList.toggle("active", active);
-
- panel.hidden = !active;
-
- });
-
-}
-
-
-
-function selectCase(caseId) {
-
- state.selectedCaseId = caseId;
-
- suggestedQueryRunSummary = null;
-
- // Reset per-case inputs. The decision memo is submitted with whatever case is
- // selected at click time, so a memo typed for case A and left in the box was
- // recorded verbatim as the rejection/correction reason for case B.
-
- resetCaseScopedInputs();
-
- switchView("workbench");
-
- switchWorkbenchTab("evidence");
- renderAll();
-
-}
-
-
-
-function resetCaseScopedInputs() {
-
- const memo = document.getElementById("decision-memo");
-
- if (memo) {
-
- memo.value = "";
-
- }
-
- const memoError = document.getElementById("memo-error");
-
- if (memoError) {
-
- memoError.textContent = "";
-
- }
-
- const manualQuery = document.getElementById("manual-query");
-
- if (manualQuery) {
-
- manualQuery.value = "";
-
- }
-
- const manualStatus = document.getElementById("manual-query-status");
-
- if (manualStatus) {
-
- manualStatus.textContent = "";
-
- }
-
-}
-
-
-
-let isDecisionInFlight = false;
-
-async function setDecision(decision, requiresMemo = false) {
-
- // In-flight guard: a double-click, or clicking two different decisions
- // quickly, would otherwise POST two conflicting decisions concurrently.
- if (isDecisionInFlight) return;
-
- const submission = getSelectedCase();
-
- const memo = document.getElementById("decision-memo").value.trim();
-
- const error = document.getElementById("memo-error");
-
-
-
- if (!submission) {
-
- error.textContent = "판정할 제출 이미지가 없습니다.";
-
- return;
-
- }
-
-
-
- if (requiresMemo && !memo) {
-
- error.textContent = "반려 또는 보정 결정에는 메모가 필요합니다.";
-
- return;
-
- }
-
-
-
- error.textContent = "";
-
- isDecisionInFlight = true;
-
- try {
-
- await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/decision`, {
-
- method: "POST",
-
- body: JSON.stringify({ decision, memo }),
-
- });
-
- document.getElementById("decision-memo").value = "";
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- error.textContent = errorValue.message;
-
- } finally {
-
- isDecisionInFlight = false;
-
- }
-
-}
-
-
-
-let isManualSearchActive = false;
-
-async function runManualSearch(event) {
-
- event.preventDefault();
-
- if (isManualSearchActive) return;
-
- const submission = getSelectedCase();
-
- const provider = document.getElementById("manual-query-provider").value;
-
- const queryInput = document.getElementById("manual-query");
-
- const status = document.getElementById("manual-query-status");
-
- const query = queryInput.value.trim();
-
-
-
- if (!submission) {
-
- status.textContent = "검색을 연결할 제출 이미지가 없습니다.";
-
- return;
-
- }
-
-
-
- if (!query) {
-
- status.textContent = "쿼리를 입력하세요.";
-
- return;
-
- }
-
-
-
- const providerConfig = providers.find((item) => item.id === provider);
-
- if (!providerConfig || !providerConfig.enabled) {
-
- status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
-
- return;
-
- }
-
-
-
- isManualSearchActive = true;
-
- try {
-
- const targetCaseId = submission.id;
-
- await apiJson("/api/search/manual", {
-
- method: "POST",
-
- body: JSON.stringify({ submission_id: targetCaseId, provider, query }),
-
- });
-
- // The operator may have moved to another case while the search ran. Naming
- // the case the evidence actually landed on prevents "added to the current
- // case" from pointing at the wrong one, and the input is only cleared when
- // it still belongs to the case we searched for.
-
- if (state.selectedCaseId === targetCaseId) {
-
- queryInput.value = "";
-
- status.textContent = `${providerLabels[provider]} 쿼리 결과가 현재 케이스 증거에 추가되었습니다.`;
-
- } else {
-
- status.textContent = `${providerLabels[provider]} 쿼리 결과가 케이스 ${targetCaseId}에 추가되었습니다.`;
-
- }
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- status.textContent = errorValue.message;
-
- } finally {
-
- isManualSearchActive = false;
-
- }
-
-}
-
-
-
-function normalizeManualSearchProvider(provider) {
-
- return window.OperatorSearch.normalizeManualSearchProvider(provider);
-
-}
-
-
-
-function rerunHistoricalQuery(button) {
-
- const queryInput = document.getElementById("manual-query");
-
- const providerSelect = document.getElementById("manual-query-provider");
-
- const provider = normalizeManualSearchProvider(button.dataset.rerunProvider);
-
- queryInput.value = button.dataset.rerunQuery || "";
-
- providerSelect.value = provider;
-
- void runManualSearch(new Event("submit"));
-
-}
-
-
-
-async function rerunEnrichment() {
-
- const submission = getSelectedCase();
-
- const button = document.getElementById("rerun-enrichment");
-
- if (!submission) {
-
- showApiError("재분석할 제출 이미지가 없습니다.");
-
- return;
-
- }
-
- if (button) button.disabled = true;
-
- try {
-
- await apiJson(`/api/submissions/${encodeURIComponent(submission.id)}/rerun-enrichment`, {
-
- method: "POST",
-
- body: JSON.stringify({}),
-
- });
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- showApiError(errorValue.message);
-
- } finally {
-
- if (button) button.disabled = false;
-
- }
-
-}
-
-
-
-function selectedBulkSubmissionIds() {
-
- const ids = Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map((input) => input.dataset.bulkId);
-
- return ids.length ? ids : state.selectedCaseId ? [state.selectedCaseId] : [];
-
-}
-
-
-
-// --- Keyboard review mode ------------------------------------------------
-
-
-
-const KEYBOARD_HELP = [
-
- "J / ↓ : 다음 케이스",
-
- "K / ↑ : 이전 케이스",
-
- "A : 승인 H : 보류 R : 반려(메모로 이동)",
-
- "N : 다음 미심사 케이스",
-
- "? : 이 도움말 열기/닫기",
-
- "Esc : 도움말 닫기",
-
-].join("\n");
-
-
-
-function isTypingTarget(target) {
-
- if (!target) return false;
-
- const tag = String(target.tagName || "").toLowerCase();
-
- return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
-
-}
-
-
-
-function moveSelection(offset) {
-
- const rows = filteredSubmissions();
-
- if (!rows.length) return;
-
- const index = rows.findIndex((item) => item.id === state.selectedCaseId);
-
- // From "no selection", J lands on the first row and K on the last.
-
- const nextIndex = index === -1
-
- ? (offset > 0 ? 0 : rows.length - 1)
-
- : Math.min(rows.length - 1, Math.max(0, index + offset));
-
- selectCase(rows[nextIndex].id);
-
-}
-
-
-
-function moveToNextUnreviewed() {
-
- const rows = filteredSubmissions();
-
- const index = rows.findIndex((item) => item.id === state.selectedCaseId);
-
- const ordered = [...rows.slice(index + 1), ...rows.slice(0, Math.max(0, index + 1))];
-
- const next = ordered.find((item) => item.decisionStatus === "unreviewed");
-
- if (next) {
-
- selectCase(next.id);
-
- } else {
-
- setImportStatus("미심사 케이스가 없습니다.", "ok");
-
- }
-
-}
-
-
-
-function renderReviewProgress() {
-
- const target = document.getElementById("review-progress");
-
- if (!target) return;
-
- const rows = filteredSubmissions();
-
- const position = rows.findIndex((item) => item.id === state.selectedCaseId) + 1;
-
- const remaining = rows.filter((item) => item.decisionStatus === "unreviewed").length;
-
- target.textContent = rows.length
-
- ? `${position || "-"}/${rows.length} · 남은 미심사 ${remaining}건 · ? 도움말`
-
- : "";
-
-}
-
-
-
-function handleReviewShortcut(event) {
-
- if (event.ctrlKey || event.metaKey || event.altKey) return;
-
- if (isTypingTarget(event.target)) return;
-
- const key = event.key;
-
- if (key === "Escape") {
-
- const help = document.getElementById("keyboard-help");
-
- if (help && !help.hidden) help.hidden = true;
-
- return;
-
- }
-
- if (key === "?") {
-
- const help = document.getElementById("keyboard-help");
-
- if (help) {
-
- help.textContent = KEYBOARD_HELP;
-
- help.hidden = !help.hidden;
-
- }
-
- event.preventDefault();
-
- return;
-
- }
-
- // Navigation works from the queue and the workbench; decisions only make
- // sense once a case is open.
-
- if (key === "j" || key === "J" || key === "ArrowDown") {
-
- moveSelection(1);
-
- event.preventDefault();
-
- return;
-
- }
-
- if (key === "k" || key === "K" || key === "ArrowUp") {
-
- moveSelection(-1);
-
- event.preventDefault();
-
- return;
-
- }
-
- if (key === "n" || key === "N") {
-
- moveToNextUnreviewed();
-
- event.preventDefault();
-
- return;
-
- }
-
- if (!state.selectedCaseId) return;
-
- if (key === "a" || key === "A") {
-
- setDecision("approved").then(() => moveToNextUnreviewed());
-
- event.preventDefault();
-
- return;
-
- }
-
- if (key === "h" || key === "H") {
-
- setDecision("held").then(() => moveToNextUnreviewed());
-
- event.preventDefault();
-
- return;
-
- }
-
- if (key === "r" || key === "R") {
-
- // Rejection needs a memo, so hand the operator the field instead of firing
- // a request that would fail validation.
-
- switchView("workbench");
-
- const memo = document.getElementById("decision-memo");
-
- if (memo) memo.focus();
-
- event.preventDefault();
-
- }
-
-}
-
-
-
-// --- Duplicate / prior-case grouping ------------------------------------
-
-
-
-function duplicateBadgeHtml(submission) {
-
- const group = submission.duplicateGroup;
-
- if (!group || !group.size) return "";
-
- const priors = group.priorDecisions || [];
-
- const priorText = priors.length
-
- ? `선례: ${priors
-
- .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
-
- .join(", ")}`
-
- : "아직 판정된 선례 없음";
-
- const tone = group.conflicting ? " conflicting" : "";
-
- const conflictNote = group.conflicting ? " · 판정 불일치" : "";
-
- return ``;
-
-}
-
-
-
-function selectDuplicateGroup(submissionId) {
-
- // Tick the whole group so the operator can hand it to batch re-analysis or a
- // batch decision in one move.
-
- const submission = submissions.find((item) => item.id === submissionId);
-
- const group = submission && submission.duplicateGroup;
-
- if (!group) return;
-
- const ids = new Set([submissionId, ...(group.peerIds || [])]);
-
- document.querySelectorAll("input[data-bulk-id]").forEach((input) => {
-
- input.checked = ids.has(input.dataset.bulkId);
-
- });
-
- renderBulkDecisionCount();
-
- const status = document.getElementById("bulk-decision-status");
-
- if (status) {
-
- const priors = group.priorDecisions || [];
-
- status.textContent = priors.length
-
- ? `동일 이미지 ${ids.size}건 선택 · 선례 ${priors
-
- .map((item) => `${item.submissionId} ${decisionLabels[item.decision] || item.decision}`)
-
- .join(", ")}`
-
- : `동일 이미지 ${ids.size}건을 선택했습니다.`;
-
- }
-
-}
-
-
-
-// --- Batch decisions ---------------------------------------------------
-
-
-
-function checkedBulkSubmissionIds() {
-
- // Unlike selectedBulkSubmissionIds(), this never falls back to the current
- // case: a batch decision must act on an explicit selection only.
-
- return Array.from(document.querySelectorAll("input[data-bulk-id]:checked")).map(
-
- (input) => input.dataset.bulkId,
-
- );
-
-}
-
-
-
-function renderBulkDecisionCount() {
-
- const target = document.getElementById("bulk-decision-count");
-
- if (!target) return;
-
- const ids = checkedBulkSubmissionIds();
-
- const highRisk = ids.filter((id) => {
-
- const submission = submissions.find((item) => item.id === id);
-
- return submission && submission.riskBand === "high";
-
- }).length;
-
- target.textContent = highRisk
-
- ? `선택 ${ids.length}건 (고위험 ${highRisk}건)`
-
- : `선택 ${ids.length}건`;
-
-}
-
-
-
-let isBulkDecisionInFlight = false;
-
-
-
-async function applyBulkDecision(decision) {
-
- if (isBulkDecisionInFlight) return;
-
- const ids = checkedBulkSubmissionIds();
-
- const status = document.getElementById("bulk-decision-status");
-
- const setStatus = (message) => {
-
- if (status) status.textContent = message;
-
- };
-
- if (!ids.length) {
-
- setStatus("판정할 케이스를 먼저 선택하세요.");
-
- return;
-
- }
-
- const memo = document.getElementById("decision-memo")?.value.trim() || "";
-
- if (decision === "rejected" && !memo) {
-
- setStatus("일괄 반려에는 결정 메모가 필요합니다. 메모를 입력한 뒤 다시 시도하세요.");
-
- return;
-
- }
-
- const highRisk = ids.filter((id) => {
-
- const submission = submissions.find((item) => item.id === id);
-
- return submission && submission.riskBand === "high";
-
- });
-
- // Guardrail: approving a high-risk case in bulk is the one irreversible-feeling
- // action here, so name the count instead of asking a generic "are you sure".
-
- if (highRisk.length) {
-
- const label = decisionLabels[decision] || decision;
-
- if (!window.confirm(`선택 ${ids.length}건 중 고위험 ${highRisk.length}건이 포함되어 있습니다. 그대로 ${label} 처리할까요?`)) {
-
- setStatus("일괄 판정을 취소했습니다.");
-
- return;
-
- }
-
- }
-
- isBulkDecisionInFlight = true;
-
- setStatus(`${ids.length}건 판정 중…`);
-
- try {
-
- const payload = await apiJson("/api/submissions/decision-batch", {
-
- method: "POST",
-
- body: JSON.stringify({ submission_ids: ids, decision, memo }),
-
- });
-
- const result = payload.batchDecision || { applied: [], failed: [] };
-
- applyBootstrap(payload);
-
- renderAll();
-
- const failures = result.failed || [];
-
- setStatus(
-
- failures.length
-
- ? `${(result.applied || []).length}건 적용 · ${failures.length}건 실패: ${failures
-
- .map((item) => `${item.submissionId}(${item.error})`)
-
- .join(", ")}`
-
- : `${(result.applied || []).length}건 판정을 적용했습니다.`,
-
- );
-
- } catch (errorValue) {
-
- setStatus(`일괄 판정 실패: ${errorValue.message}`);
-
- } finally {
-
- isBulkDecisionInFlight = false;
-
- }
-
-}
-
-
-
-// --- Decision memo presets ---------------------------------------------
-
-
-
-function renderMemoPresets() {
-
- const target = document.getElementById("memo-presets");
-
- if (!target) return;
-
- const chips = decisionMemoPresets
-
- .map(
-
- (preset) =>
-
- ``,
-
- )
-
- .join("");
-
- target.innerHTML = `${chips}`;
-
-}
-
-
-
-function applyMemoPreset(presetId) {
-
- const preset = decisionMemoPresets.find((item) => item.id === presetId);
-
- const memo = document.getElementById("decision-memo");
-
- if (!preset || !memo) return;
-
- memo.value = memo.value.trim() ? `${memo.value.trim()} ${preset.body}` : preset.body;
-
- memo.focus();
-
-}
-
-
-
-async function saveCurrentMemoAsPreset() {
-
- const memo = document.getElementById("decision-memo");
-
- const error = document.getElementById("memo-error");
-
- const body = memo?.value.trim() || "";
-
- if (!body) {
-
- if (error) error.textContent = "저장할 메모 내용이 없습니다.";
-
- return;
-
- }
-
- const label = window.prompt("프리셋 이름 (예: 연예인 초상)", body.slice(0, 20));
-
- if (!label) return;
-
- try {
-
- const presets = await apiJson("/api/decision-memo-presets", {
-
- method: "POST",
-
- body: JSON.stringify({ label, body }),
-
- });
-
- replaceCollection(decisionMemoPresets, presets);
-
- renderMemoPresets();
-
- if (error) error.textContent = "";
-
- } catch (errorValue) {
-
- if (error) error.textContent = `프리셋 저장 실패: ${errorValue.message}`;
-
- }
-
-}
-
-
-
-// --- Decision export ----------------------------------------------------
-
-
-
-async function exportDecisions() {
-
- const status = document.getElementById("bulk-decision-status");
-
- const audience = window.confirm(
-
- "내부 보관용(증거·점수 포함)으로 내보내려면 확인, 신청 채널 전달용(판정·메모만)으로 내보내려면 취소를 누르세요.",
-
- )
-
- ? "internal"
-
- : "applicant";
-
- try {
-
- const payload = await apiJson(
-
- `/api/decisions/export?audience=${encodeURIComponent(audience)}`,
-
- );
-
- if (!payload.rows.length) {
-
- if (status) status.textContent = "내보낼 판정 완료 케이스가 없습니다.";
-
- return;
-
- }
-
- downloadTextFile(
-
- `copyrighter-decisions-${audience}.csv`,
-
- payload.csv,
-
- "text/csv;charset=utf-8",
-
- );
-
- if (status) status.textContent = `${payload.rows.length}건을 ${audience === "internal" ? "내부 보관용" : "신청 채널 전달용"} CSV로 내보냈습니다.`;
-
- } catch (errorValue) {
-
- if (status) status.textContent = `내보내기 실패: ${errorValue.message}`;
-
- }
-
-}
-
-
-
-function downloadTextFile(filename, text, mimeType) {
-
- // BOM so Excel opens the Korean CSV as UTF-8 instead of the system codepage.
-
- const blob = new Blob([`${text}`], { type: mimeType });
-
- const url = URL.createObjectURL(blob);
-
- const link = document.createElement("a");
-
- link.href = url;
-
- link.download = filename;
-
- document.body.appendChild(link);
-
- link.click();
-
- document.body.removeChild(link);
-
- URL.revokeObjectURL(url);
-
-}
-
-
-
-// --- Backup -------------------------------------------------------------
-
-
-
-async function createBackupSnapshot() {
-
- const status = document.getElementById("bulk-decision-status");
-
- const button = document.getElementById("create-backup");
-
- if (button) button.disabled = true;
-
- if (status) status.textContent = "백업 스냅샷을 만드는 중…";
-
- try {
-
- const result = await apiJson("/api/admin/backup", { method: "POST" });
-
- const megabytes = (Number(result.sizeBytes || 0) / (1024 * 1024)).toFixed(1);
-
- if (status) {
-
- status.textContent = `백업 완료: ${result.file} (${megabytes}MB · 미디어 ${result.mediaFiles}개)`;
-
- }
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- if (status) status.textContent = `백업 실패: ${errorValue.message}`;
-
- } finally {
-
- if (button) button.disabled = false;
-
- }
-
-}
-
-
-
-// --- Cleared reference (positive knowledge) -----------------------------
-
-
-
-async function registerClearedReference() {
-
- const submission = getSelectedCase();
-
- if (!submission) return;
-
- const label = window.prompt(
-
- "클리어 기준 이름 (예: 자사 보유 스톡 · 라이선스 확인됨)",
-
- submission.title || submission.id,
-
- );
-
- if (label === null) return;
-
- const note = window.prompt("메모(선택) — 왜 클리어인지", "") || "";
-
- try {
-
- const payload = await apiJson(
-
- `/api/submissions/${encodeURIComponent(submission.id)}/clear-reference`,
-
- { method: "POST", body: JSON.stringify({ label: label.trim(), note }) },
-
- );
-
- applyBootstrap(payload);
-
- renderAll();
-
- } catch (errorValue) {
-
- showApiError(errorValue.message);
-
- }
-
-}
-
-
-
-// --- LLM draft (persist + show + pull into memo) ------------------------
-
-
-
-function llmSummaryFor(submission) {
-
- const evidence = submission && Array.isArray(submission.evidence) ? submission.evidence : [];
-
- // The freshest LLM summary that actually carries body text.
-
- return (
-
- evidence
-
- .filter((item) => item.source === "llm" && String(item.summary || "").trim())
-
- .slice(-1)[0] || null
-
- );
-
-}
-
-
-
-let currentLlmDraftText = "";
-
-
-
-function renderLlmDraft(submission) {
-
- const box = document.getElementById("llm-draft");
-
- if (!box) return;
-
- const summary = llmSummaryFor(submission);
-
- if (!summary) {
-
- box.hidden = true;
-
- box.innerHTML = "";
-
- currentLlmDraftText = "";
-
- return;
-
- }
-
- currentLlmDraftText = String(summary.summary || "");
-
- const urls = Array.isArray(summary.sourceUrls) ? summary.sourceUrls : [];
-
- const sourceNote = urls.length ? `연결 근거 ${urls.length}건` : "연결 근거 없음";
-
- box.hidden = false;
-
- box.innerHTML = `
-
-
-
- 내부 요약(초안)
-
- ${escapeHtml(sourceNote)} · 최종 판정은 운영자
-
-
-
- ${escapeHtml(currentLlmDraftText)}
-
-
-
- `;
-
-}
-
-
-
-function pullLlmDraftIntoMemo() {
-
- const memo = document.getElementById("decision-memo");
-
- if (!memo || !currentLlmDraftText) return;
-
- memo.value = memo.value.trim()
-
- ? `${memo.value.trim()}\n${currentLlmDraftText}`
-
- : currentLlmDraftText;
-
- memo.focus();
-
-}
-
-
-
-// --- Decision statistics / calibration ----------------------------------
-
-
-
-const DECISION_STAT_LABELS = {
-
- approved: "승인",
-
- held: "보류",
-
- rejected: "반려",
-
- corrected: "보정",
-
- unreviewed: "미심사",
-
-};
-
-const BAND_STAT_LABELS = {
-
- high: "고위험",
-
- medium: "중위험",
-
- low: "저위험",
-
- failed: "분석실패",
-
- pending: "대기",
-
-};
-
-
-
-async function loadDecisionStats() {
-
- const body = document.getElementById("stats-body");
-
- if (!body) return;
-
- body.innerHTML = `통계를 불러오는 중…
`;
-
- try {
-
- const stats = await apiJson("/api/stats/decisions");
-
- renderDecisionStats(stats);
-
- } catch (errorValue) {
-
- body.innerHTML = `통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}
`;
-
- }
-
-}
-
-
-
-function calibrationTone(rate, decided) {
-
- // Not enough decisions to judge -> neutral, don't cry miscalibration on n=1.
-
- if (decided < 5) return "neutral";
-
- if (rate >= 0.4) return "bad";
-
- if (rate >= 0.2) return "warn";
-
- return "good";
-
-}
-
-
-
-function renderDecisionStats(stats) {
-
- const body = document.getElementById("stats-body");
-
- if (!body) return;
-
- const cal = stats.calibration || {};
-
- const throughput = stats.throughput || { byDay: [] };
-
- const backlog = stats.backlog || { unreviewed: 0, total: 0 };
-
-
-
- const overTone = calibrationTone(cal.highBandApprovalRate || 0, cal.highBandDecided || 0);
-
- const underTone = calibrationTone(cal.lowBandRejectionRate || 0, cal.lowBandDecided || 0);
-
-
-
- const tiles = `
-
-
-
-
-
- 과탐 — 고위험인데 승인
-
- ${formatPercent(cal.highBandApprovalRate)}
-
- 고위험 판정 ${cal.highBandDecided || 0}건 기준
-
-
-
-
-
- 미탐 — 저위험인데 반려
-
- ${formatPercent(cal.lowBandRejectionRate)}
-
- 저위험 판정 ${cal.lowBandDecided || 0}건 기준
-
-
-
-
-
- 미심사 백로그
-
- ${backlog.unreviewed}
-
- 전체 ${backlog.total}건 중
-
-
-
-
-
- 누적 판정
-
- ${throughput.totalDecisions || 0}
-
- 감사 로그 기준
-
-
-
-
-
- `;
-
-
-
- body.innerHTML = `
-
- ${tiles}
-
-
-
-
-
- 밴드 × 판정 매트릭스
-
- 각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.
-
- ${renderStatsMatrix(stats)}
-
-
-
-
-
- 최근 결정 추이
-
- 최근 14일 일별 판정 건수.
-
- ${renderThroughputBars(throughput.byDay || [])}
-
-
-
-
-
- `;
-
-}
-
-
-
-function renderStatsMatrix(stats) {
-
- const bands = stats.bands || [];
-
- const decisions = stats.decisions || [];
-
- const matrix = stats.matrix || {};
-
- const head = decisions.map((d) => `${escapeHtml(DECISION_STAT_LABELS[d] || d)} | `).join("");
-
- const rows = bands
-
- .map((band) => {
-
- const row = matrix[band] || {};
-
- const total = decisions.reduce((sum, d) => sum + (row[d] || 0), 0);
-
- const cells = decisions
-
- .map((d) => {
-
- const value = row[d] || 0;
-
- const strong = value > 0 ? " has-value" : "";
-
- return `${value} | `;
-
- })
-
- .join("");
-
- return `| ${escapeHtml(BAND_STAT_LABELS[band] || band)} | ${cells}${total} |
`;
-
- })
-
- .join("");
-
- return `
-
-
-
-
-
- | 밴드 \\ 판정 | ${head}합계 |
-
- ${rows}
-
-
-
-
-
- `;
-
-}
-
-
-
-function renderThroughputBars(byDay) {
-
- if (!byDay.length) {
-
- return `아직 기록된 판정이 없습니다.
`;
-
- }
-
- const max = Math.max(...byDay.map((entry) => entry.count), 1);
-
- const bars = byDay
-
- .map((entry) => {
-
- const height = Math.round((entry.count / max) * 100);
-
- return `
-
-
-
-
-
-
${entry.count}
-
-
${escapeHtml(entry.day.slice(5))}
-
-
-
- `;
-
- })
-
- .join("");
-
- return `${bars}
`;
-
-}
-
-
-
-function formatPercent(rate) {
-
- return `${Math.round((Number(rate) || 0) * 100)}%`;
-
-}
-
-
-
-async function rerunSelectedEnrichment() {
-
- const ids = selectedBulkSubmissionIds();
-
- const button = document.getElementById("bulk-rerun");
-
- if (!ids.length) {
-
- setImportStatus("재분석할 제출이 없습니다.", "bad");
- return;
-
- }
-
-
-
- if (button) button.disabled = true;
-
- setImportStatus(`${ids.length}건 재분석을 시작합니다.`, "warn");
- try {
-
- for (const [index, submissionId] of ids.entries()) {
-
- setImportStatus(`${index + 1}/${ids.length} 재분석 중 · ${submissionId}`, "warn");
- await apiJson(`/api/submissions/${encodeURIComponent(submissionId)}/rerun-enrichment`, {
-
- method: "POST",
-
- body: JSON.stringify({}),
-
- });
-
- }
-
- state.selectedCaseId = ids[0];
-
- await refreshFromApi();
-
- setImportStatus(`${ids.length}건 재분석 완료`, "ok");
- } catch (errorValue) {
-
- // Best-effort refresh so already-rerun submissions aren't left stale in the
- // UI when the loop fails partway through.
- try { await refreshFromApi(); } catch (_refreshError) { /* keep original error */ }
-
- setImportStatus(errorValue.message, "bad");
-
- } finally {
-
- if (button) button.disabled = false;
-
- }
-
-}
-
-
-
-function resetQueueFiltersForImport() {
-
- state.filters.risk = "all";
-
- state.filters.source = "all";
-
- state.filters.decision = "all";
-
- state.filters.sort = "newest";
-
- state.filters.query = "";
-
- document.querySelectorAll("#risk-filter button").forEach((button) => {
-
- button.classList.toggle("active", button.dataset.risk === "all");
-
- });
-
- document.getElementById("source-filter").value = "all";
-
- document.getElementById("decision-filter").value = "all";
-
- document.getElementById("queue-sort").value = "newest";
-
- document.getElementById("queue-search").value = "";
-
-}
-
-
-
-function importedFolderStatusMessage(payload, fallbackLabel) {
-
- return window.OperatorSubmissionImport.importedFolderStatusMessage(payload, fallbackLabel);
-
-}
-
-
-
-function importedSubmissionStatusMessage(payload, submissionId) {
-
- return window.OperatorSubmissionImport.importedSubmissionStatusMessage(payload, submissionId);
-
-}
-
-
-
-async function reloadSubmissions() {
-
- const button = document.getElementById("reload-submissions");
-
- const folderInput = document.getElementById("submission-folder");
-
- const folderPath = (folderInput?.value || "").trim();
-
- const endpoint = folderPath ? "/api/submissions/import-folder" : "/api/submissions/reload";
-
- const body = folderPath ? { path: folderPath } : {};
-
- button.disabled = true;
-
- setImportStatus("제출 폴더를 읽는 중입니다.", "warn");
-
- try {
-
- const payload = await apiJson(endpoint, {
-
- method: "POST",
-
- body: JSON.stringify(body),
-
- });
-
- // Snapshot the pre-reload ids BEFORE applyBootstrap replaces `submissions`,
- // otherwise the imported-diff below always compares against the new list and
- // is always empty (no new case is ever auto-selected).
- const existingIds = new Set(submissions.map((submission) => submission.id));
-
- applyBootstrap(payload);
-
- if (folderPath) {
-
- folderInput.value = "";
-
- const importedFolderSubmissions = payload.submissions || [];
-
- if (importedFolderSubmissions[0]) {
-
- state.selectedCaseId = importedFolderSubmissions[0].id;
-
- resetQueueFiltersForImport();
-
- setImportStatus(importedFolderStatusMessage(payload, folderPath), "ok");
-
- } else {
-
- setImportStatus("추가된 제출 없음", "idle");
-
- }
-
- } else {
-
- const importedSubmissions = (payload.submissions || []).filter((submission) => !existingIds.has(submission.id));
-
- if (importedSubmissions.length) {
-
- state.selectedCaseId = importedSubmissions[0].id;
-
- resetQueueFiltersForImport();
-
- setImportStatus(importedSubmissionStatusMessage(payload, importedSubmissions[0].id), "ok");
-
- } else {
-
- setImportStatus("추가된 제출 없음", "idle");
-
- }
-
- }
-
- renderAll();
-
- } catch (errorValue) {
-
- setImportStatus(errorValue.message, "bad");
-
- } finally {
-
- button.disabled = false;
-
- }
-
-}
-
-
-function updateSubmissionImageName() {
-
- const input = document.getElementById("submission-image");
-
- const target = document.getElementById("submission-image-name");
-
- if (!input || !target) return;
-
- target.textContent = input.files[0]?.name || "선택된 파일 없음";
-
-}
-
-
-
-async function uploadSubmissionImage() {
-
- const input = document.getElementById("submission-image");
-
- const button = document.getElementById("upload-submission-image");
-
- const file = input?.files?.[0];
-
- if (!file) {
-
- setImportStatus("추가할 사진을 선택하세요.", "bad");
-
- return;
-
- }
-
- button.disabled = true;
-
- setImportStatus("사진을 현재 제출 폴더에 넣는 중입니다.", "warn");
-
- try {
-
- const image = await readKnowledgeImage(file);
-
- const payload = await apiJson("/api/submissions/upload-image", {
-
- method: "POST",
-
- body: JSON.stringify({ image }),
-
- });
-
- applyBootstrap(payload);
-
- input.value = "";
-
- updateSubmissionImageName();
-
- if (payload.uploadedSubmissionId) {
-
- state.selectedCaseId = payload.uploadedSubmissionId;
-
- resetQueueFiltersForImport();
-
- switchView("workbench");
-
- switchWorkbenchTab("evidence");
-
- }
-
- setImportStatus(`${payload.uploadedSubmissionId || file.name} 사진이 추가되었습니다. 새 심사 건으로 바로 선택했습니다.`, "ok");
-
- renderAll();
-
- } catch (errorValue) {
-
- setImportStatus(errorValue.message, "bad");
-
- } finally {
-
- button.disabled = false;
-
- }
-
-}
-
-
-
-async function readKnowledgeImage(file) {
-
- return window.OperatorSubmissionImport.fileToImagePayload(file);
-
-}
-
-
-function updateKnowledgeImageName() {
-
- const input = document.getElementById("knowledge-image");
-
- const target = document.getElementById("knowledge-image-name");
-
- if (!input || !target) return;
-
- target.textContent = input.files[0]?.name || "선택된 파일 없음";
-
-}
-
-
-
-async function addKnowledgeEntry(event) {
-
- event.preventDefault();
-
- const name = document.getElementById("knowledge-name").value.trim();
-
- const type = document.getElementById("knowledge-type").value;
-
- const aliases = document
-
- .getElementById("knowledge-aliases")
-
- .value.split(",")
-
- .map((item) => item.trim())
-
- .filter(Boolean);
-
- const keywords = document
-
- .getElementById("knowledge-keywords")
-
- .value.split(",")
-
- .map((item) => item.trim())
-
- .filter(Boolean);
-
- const memo = document.getElementById("knowledge-memo").value.trim();
-
- const imageInput = document.getElementById("knowledge-image");
-
- const status = document.getElementById("knowledge-entry-status");
-
- const submitButton = event.target.querySelector('button[type="submit"]');
-
-
-
- if (!name || !memo) return;
-
-
-
- submitButton.disabled = true;
-
- status.textContent = "기준 DB에 저장하는 중입니다.";
-
- try {
-
- const image = await readKnowledgeImage(imageInput.files[0]);
-
- const payload = await apiJson("/api/knowledge/manual", {
-
- method: "POST",
-
- body: JSON.stringify({ name, type, aliases, keywords, memo, image }),
-
- });
-
- applyBootstrap(payload);
-
- event.target.reset();
-
- updateKnowledgeImageName();
-
- status.textContent = image ? "참조 이미지와 이미지 지문을 저장했습니다." : "기준 DB에 저장했습니다.";
-
- renderAll();
-
- } catch (errorValue) {
-
- status.textContent = errorValue.message;
-
- } finally {
-
- submitButton.disabled = false;
-
- }
-
-}
-
-
-
-function clearCollectionCandidatesForSearch() {
-
- collectionCandidates.splice(0, collectionCandidates.length);
-
- renderCollectionCandidates();
-
-}
-
-
-
-function clearCollectionCandidatesAfterAction() {
-
- collectionCandidates.splice(0, collectionCandidates.length);
-
- state.currentCollectionQuery = "";
-
- state.currentCollectionProvider = "";
-
- renderCollectionCandidates();
-
-}
-
-
-
-async function dismissCollectionCandidate(candidateId) {
-
- const status = document.getElementById("collection-status");
-
- if (status) status.textContent = "후보를 무시하는 중입니다.";
-
- try {
-
- const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/dismiss`, {
-
- method: "POST",
-
- body: JSON.stringify({}),
-
- });
-
- applyBootstrap(payload);
-
- renderAll();
-
- if (status) status.textContent = "후보를 무시했습니다.";
-
- } catch (errorValue) {
-
- if (status) status.textContent = errorValue.message;
-
- }
-
-}
-
-
-
-async function runCandidateCollection(event) {
-
- event.preventDefault();
-
- const provider = document.getElementById("collection-provider").value;
-
- const queryInput = document.getElementById("collection-query");
-
- const status = document.getElementById("collection-status");
-
- const submitButton = event.target.querySelector('button[type="submit"]');
-
- const query = queryInput.value.trim();
-
-
-
- if (!query) {
-
- state.currentCollectionQuery = "";
-
- state.currentCollectionProvider = "";
-
- renderCollectionCandidates();
-
- status.textContent = "수집할 키워드를 입력하세요.";
-
- return;
-
- }
-
-
-
- const providerConfig = providers.find((item) => item.id === provider);
-
- if (!providerConfig || !providerConfig.enabled) {
-
- status.textContent = `${providerLabels[provider] || provider} 외부 검색 tool이 비활성입니다.`;
-
- return;
-
- }
-
-
-
- submitButton.disabled = true;
-
- status.textContent = "키워드 후보를 수집하는 중입니다.";
-
- try {
-
- const payload = await apiJson("/api/collections/keyword", {
-
- method: "POST",
-
- body: JSON.stringify({ provider, query }),
-
- });
-
- // Commit the new query/provider and swap in the new candidates only after the
- // request succeeds, so a failed collection doesn't wipe the previous view.
- state.currentCollectionQuery = query;
-
- state.currentCollectionProvider = provider;
-
- applyBootstrap(payload);
-
- renderAll();
-
- status.textContent = `${payload.collected || 0}개후보를 수집했습니다.`;
-
- } catch (errorValue) {
-
- status.textContent = errorValue.message;
-
- } finally {
-
- submitButton.disabled = false;
-
- }
-
-}
-
-
-
-function selectableCollectionCandidateInputs() {
-
- return Array.from(document.querySelectorAll("input[data-collection-candidate-id]:not(:disabled)"));
-
-}
-
-
-
-function selectedCollectionCandidateIds() {
-
- return selectableCollectionCandidateInputs()
-
- .filter((input) => input.checked)
-
- .map((input) => input.dataset.collectionCandidateId);
-
-}
-
-
-
-function setAllCollectionCandidateSelection(selected) {
-
- const inputs = selectableCollectionCandidateInputs();
-
- const status = document.getElementById("collection-status");
-
-
-
- inputs.forEach((input) => {
-
- input.checked = selected;
-
- });
-
-
-
- if (!inputs.length) {
-
- status.textContent = "선택된 후보가 없습니다.";
-
- return;
-
- }
-
-
-
- status.textContent = selected ? `${inputs.length}개 후보를 선택했습니다.` : "후보 선택을 모두 해제했습니다.";
-
-}
-
-
-
-function commaSeparatedValues(elementId) {
-
- return document
-
- .getElementById(elementId)
-
- .value.split(",")
-
- .map((item) => item.trim())
-
- .filter(Boolean);
-
-}
-
-
-
-async function promoteSelectedCollectionCandidates(event) {
-
- event.preventDefault();
-
- const candidateIds = selectedCollectionCandidateIds();
-
- const status = document.getElementById("collection-status");
-
- const submitButton = document.getElementById("promote-selected-candidates");
-
-
-
- if (!candidateIds.length) {
-
- status.textContent = "반영할 후보를 선택하세요.";
-
- return;
-
- }
-
-
-
- submitButton.disabled = true;
-
- status.textContent = "선택한 후보를 기준 DB에 반영하는 중입니다.";
-
- try {
-
- const payload = await apiJson("/api/collections/candidates/promote-batch", {
-
- method: "POST",
-
- body: JSON.stringify({
-
- candidate_ids: candidateIds,
-
- name: document.getElementById("collection-promotion-name").value.trim(),
-
- type: document.getElementById("collection-promotion-type").value,
-
- aliases: commaSeparatedValues("collection-promotion-aliases"),
-
- keywords: commaSeparatedValues("collection-promotion-keywords"),
-
- memo: document.getElementById("collection-promotion-memo").value.trim(),
-
- }),
-
- });
-
- applyBootstrap(payload);
-
- clearCollectionCandidatesAfterAction();
-
- event.target.reset();
-
- renderAll();
-
- status.textContent = `${candidateIds.length}개후보를 기준 DB에 반영했습니다.`;
-
- } catch (errorValue) {
-
- status.textContent = errorValue.message;
-
- } finally {
-
- submitButton.disabled = false;
-
- }
-
-}
-
-
-
-async function promoteCollectionCandidate(candidateId) {
-
- const status = document.getElementById("collection-status");
-
- status.textContent = "후보를 기준 DB에 반영하는 중입니다.";
-
- try {
-
- const payload = await apiJson(`/api/collections/candidates/${encodeURIComponent(candidateId)}/promote`, {
-
- method: "POST",
-
- body: JSON.stringify({}),
-
- });
-
- applyBootstrap(payload);
-
- clearCollectionCandidatesAfterAction();
-
- renderAll();
-
- status.textContent = "후보를 기준 DB에 반영했습니다.";
-
- } catch (errorValue) {
-
- status.textContent = errorValue.message;
-
- }
-
-}
-
-
-
-function addKnowledgeEntryFromCase() {
-
- // The control is labelled "주의 후보 보기" (view watchlist candidates), so show
- // the registered list filtered to watchlist — it previously opened the blank
- // manual-registration form, which is a different action entirely.
-
- switchView("knowledge");
-
- switchKnowledgeTab("registered");
-
- knowledgeFilters.status = "watchlist";
-
- const statusFilter = document.getElementById("knowledge-status-filter");
-
- if (statusFilter) statusFilter.value = "watchlist";
-
- renderAll();
-
-}
-
-
-
-async function deactivateCorrectionEntry(correctionId) {
- const correction = corrections.find((item) => item.id === correctionId);
- if (!correction) return;
-
- // Deactivate the derived knowledge entries through the real backend endpoint
- // so the change is persisted and audited server-side. The previous version
- // only mutated local state and appended a fake audit event, so the action was
- // silently discarded on the next refreshFromApi().
- // Only entries that are still active need deactivating. Skipping already-
- // inactive ones avoids the server's "이미 비활성 상태입니다" 400, which used to
- // abort the whole loop on the first inactive entry.
- const entryIds = (correction.derivedEntries || [])
- .filter((entry) => entry && entry.id && entry.active)
- .map((entry) => entry.id);
- if (!entryIds.length) {
- showApiError("이 보정 항목에는 비활성화할 활성 파생 지식 항목이 없습니다.");
- return;
- }
-
- const failures = [];
- try {
- for (const entryId of entryIds) {
- try {
- await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
- method: "POST",
- body: JSON.stringify({ reason: "보정 파생 항목 일괄 비활성화" }),
- });
- } catch (_perEntryError) {
- // One entry failing must not strand the rest.
- failures.push(entryId);
- }
- }
- } finally {
- // Always refresh so the UI reflects whatever was actually persisted, even on
- // a partial failure. Guard it so a refresh error isn't an unhandled rejection.
- try {
- await refreshFromApi();
- } catch (_refreshError) {
- /* keep the per-entry failure summary below */
- }
- }
- if (failures.length) {
- showApiError(`일부 파생 항목 비활성화에 실패했습니다 (${failures.length}건).`);
- }
-}
-
-
-
-async function toggleProvider(providerId) {
-
- const provider = providers.find((item) => item.id === providerId);
-
- if (!provider) return;
-
- try {
-
- await apiJson(`/api/providers/${encodeURIComponent(providerId)}`, {
-
- method: "PATCH",
-
- body: JSON.stringify({ enabled: !provider.enabled }),
-
- });
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- showApiError(errorValue.message);
-
- }
-
-}
-
-
-
-async function setEvidenceStatus(evidenceId, status, note = "") {
-
- const submission = getSelectedCase();
-
- if (!submission || !evidenceId) return;
-
- try {
-
- await apiJson(`/api/evidence/${encodeURIComponent(evidenceId)}/status`, {
-
- method: "POST",
-
- body: JSON.stringify({ submission_id: submission.id, status: evidenceStatusPayload(status), note }),
-
- });
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- showApiError(errorValue.message);
-
- }
-
-}
-
-
-
-function markEvidenceIrrelevant(evidenceId) {
-
- const submission = getSelectedCase();
-
- if (!submission) return;
-
- const fallback = submission.evidence.find((item) => item.contributed);
-
- const targetId = evidenceId || fallback?.id;
-
- void setEvidenceStatus(targetId, "ignored", "운영상 미사용 처리");
-
-}
-
-
-
-async function saveKnowledgeEntryEdit(form) {
- const entryId = form.dataset.knowledgeEditForm;
- const body = {
- aliases: form.elements.aliases.value.split(",").map((item) => item.trim()).filter(Boolean),
- keywords: form.elements.keywords.value.split(",").map((item) => item.trim()).filter(Boolean),
- memo: form.elements.memo.value.trim(),
- };
- try {
- const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}`, {
- method: "PATCH",
- body: JSON.stringify(body),
- });
- editingKnowledgeEntryId = "";
- applyBootstrap(payload);
- renderAll();
- } catch (errorValue) {
- showApiError(errorValue.message);
- }
-}
-
-
-async function deactivateKnowledgeEntry(entryId) {
- const reason = window.prompt("비활성 사유 메모(선택)");
- if (reason === null) return;
- try {
- const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, {
- method: "POST",
- body: JSON.stringify({ reason: reason.trim() }),
- });
- applyBootstrap(payload);
- renderAll();
- } catch (errorValue) {
- showApiError(errorValue.message);
- }
-}
-
-
-async function reactivateKnowledgeEntry(entryId) {
- const reason = window.prompt("재활성 사유 메모(필수)") || "";
- if (!reason.trim()) {
- showApiError("재활성에는 사유 메모가 필요합니다.");
- return;
- }
- try {
- const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/reactivate`, {
- method: "POST",
- body: JSON.stringify({ reason: reason.trim() }),
- });
- applyBootstrap(payload);
- renderAll();
- } catch (errorValue) {
- showApiError(errorValue.message);
- }
-}
-
-
-
-async function promoteWatchlistEntry(entryId) {
-
- try {
-
- const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/promote-watchlist`, {
-
- method: "POST",
-
- body: JSON.stringify({}),
-
- });
-
- applyBootstrap(payload);
-
- renderAll();
-
- } catch (errorValue) {
-
- showApiError(errorValue.message);
-
- }
-
-}
-
-
-
-async function excludeWatchlistEntry(entryId) {
-
- try {
-
- const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/exclude-watchlist`, {
-
- method: "POST",
-
- body: JSON.stringify({ reason: "오탐 확인" }),
-
- });
-
- applyBootstrap(payload);
-
- renderAll();
-
- } catch (errorValue) {
-
- showApiError(errorValue.message);
-
- }
-
-}
-
-
-
-async function emergencyDisableExternalProviders() {
-
- try {
-
- await apiJson("/api/providers/emergency-disable", {
-
- method: "POST",
-
- body: JSON.stringify({}),
-
- });
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- showApiError(errorValue.message);
-
- }
-
-}
-
-
-
-function bindEvents() {
-
- document.querySelectorAll(".nav-button").forEach((button) => {
-
- button.addEventListener("click", () => switchView(button.dataset.view));
-
- });
-
-
-
- document.querySelectorAll("#risk-filter button").forEach((button) => {
-
- button.addEventListener("click", () => {
-
- state.filters.risk = button.dataset.risk;
-
- document.querySelectorAll("#risk-filter button").forEach((item) => item.classList.toggle("active", item === button));
-
- renderQueue();
-
- });
-
- });
-
-
-
- document.getElementById("source-filter").addEventListener("change", (event) => {
-
- state.filters.source = event.target.value;
-
- renderCoverageTabs();
-
- renderQueue();
-
- });
-
- document.getElementById("decision-filter").addEventListener("change", (event) => {
-
- state.filters.decision = event.target.value;
-
- renderQueue();
-
- });
-
- document.getElementById("queue-sort").addEventListener("change", (event) => {
-
- state.filters.sort = event.target.value;
-
- renderQueue();
-
- });
-
- document.getElementById("queue-search").addEventListener("input", (event) => {
-
- state.filters.query = event.target.value;
-
- renderQueue();
-
- });
-
-
-
- document.getElementById("queue-body").addEventListener("click", (event) => {
-
- const duplicateButton = event.target.closest("[data-duplicate-group]");
-
- if (duplicateButton) {
-
- selectDuplicateGroup(duplicateButton.dataset.duplicateGroup);
-
- return;
-
- }
-
- const selectButton = event.target.closest("[data-select-case]");
-
- if (selectButton) {
-
- selectCase(selectButton.dataset.selectCase);
-
- return;
-
- }
-
- const row = event.target.closest("[data-row-case]");
-
- if (row && !event.target.matches("input")) {
-
- selectCase(row.dataset.rowCase);
-
- }
-
- });
-
-
-
- // Keep the batch-decision counter in step with the row checkboxes.
-
- document.getElementById("queue-body").addEventListener("change", (event) => {
-
- if (event.target.matches("input[data-bulk-id]")) {
-
- renderBulkDecisionCount();
-
- }
-
- });
-
-
-
- document.getElementById("coverage-tabs").addEventListener("click", (event) => {
-
- const tab = event.target.closest("[data-coverage-filter]");
-
- if (!tab) return;
-
- applyCoverageFilter(tab.dataset.coverageFilter);
-
- });
-
-
-
- document.querySelectorAll("[data-workbench-tab]").forEach((button) => {
-
- button.addEventListener("click", () => switchWorkbenchTab(button.dataset.workbenchTab));
-
- });
-
-
-
- document.querySelectorAll("[data-knowledge-tab]").forEach((button) => {
-
- button.addEventListener("click", () => switchKnowledgeTab(button.dataset.knowledgeTab));
-
- });
-
-
-
- document.querySelectorAll("[data-decision]").forEach((button) => {
-
- button.addEventListener("click", () => {
-
- const requiresMemo = button.dataset.requiresMemo === "true";
-
- setDecision(button.dataset.decision, requiresMemo);
-
- });
-
- });
-
-
-
- document.getElementById("manual-query-form").addEventListener("submit", runManualSearch);
-
- document.getElementById("candidate-collection-form").addEventListener("submit", runCandidateCollection);
-
- document.getElementById("collection-promotion-form").addEventListener("submit", promoteSelectedCollectionCandidates);
-
- document.getElementById("select-all-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(true));
-
- document.getElementById("clear-selected-candidates").addEventListener("click", () => setAllCollectionCandidateSelection(false));
-
- document.getElementById("knowledge-form").addEventListener("submit", addKnowledgeEntry);
-
- document.getElementById("knowledge-search").addEventListener("input", (event) => {
- knowledgeFilters.query = event.target.value;
- renderKnowledgeBase();
- });
- [
- ["knowledge-type-filter", "type"],
- ["knowledge-status-filter", "status"],
- ["knowledge-active-filter", "active"],
- ].forEach(([elementId, key]) => {
- document.getElementById(elementId).addEventListener("change", (event) => {
- knowledgeFilters[key] = event.target.value;
- renderKnowledgeBase();
- });
- });
- document.addEventListener("submit", (event) => {
- const editFormElement = event.target.closest("[data-knowledge-edit-form]");
- if (!editFormElement) return;
- event.preventDefault();
- void saveKnowledgeEntryEdit(editFormElement);
- });
-
- document.getElementById("knowledge-image").addEventListener("change", updateKnowledgeImageName);
-
- document.getElementById("reload-submissions").addEventListener("click", reloadSubmissions);
- document.getElementById("submission-image").addEventListener("change", updateSubmissionImageName);
- document.getElementById("upload-submission-image").addEventListener("click", uploadSubmissionImage);
- document.getElementById("rerun-enrichment").addEventListener("click", rerunEnrichment);
-
- document.getElementById("add-from-case").addEventListener("click", addKnowledgeEntryFromCase);
-
- document.getElementById("mark-irrelevant").addEventListener("click", () => markEvidenceIrrelevant());
-
- document.getElementById("disable-derived").addEventListener("click", () => {
- // Scope the deactivation to the currently-selected case. Picking the first
- // automatic entry globally could deactivate an entry that belongs to a
- // different submission entirely.
- const selected = getSelectedCase();
- const automaticEntry = selected
- ? knowledgeEntries.find(
- (entry) =>
- entry.provenance === "automatic" &&
- entry.active &&
- (entry.entryStatus || "confirmed") === "confirmed" &&
- entry.sourceSubmissionId === selected.id,
- )
- : undefined;
- if (automaticEntry) {
- void deactivateKnowledgeEntry(automaticEntry.id);
- } else {
- showApiError("이 케이스에서 비활성화할 자동 지식 항목이 없습니다.");
- }
- });
-
- // Image viewer controls. Without handlers these were dead buttons; they now
- // adjust the case image (fit / actual size / rotate) with state on the element
- // so renderCaseReview can reset them per case.
- document.getElementById("fit-image").addEventListener("click", () => {
- const image = document.getElementById("case-image");
- if (image) image.style.objectFit = "contain";
- });
-
- document.getElementById("actual-image").addEventListener("click", () => {
- const image = document.getElementById("case-image");
- if (image) image.style.objectFit = "none";
- });
-
- document.getElementById("rotate-image").addEventListener("click", () => {
- const image = document.getElementById("case-image");
- if (!image) return;
- const next = ((Number(image.dataset.rotation || "0") + 90) % 360 + 360) % 360;
- image.dataset.rotation = String(next);
- image.style.transform = `rotate(${next}deg)`;
- });
-
- document.getElementById("open-correction").addEventListener("click", () => {
-
- switchView("knowledge");
-
- switchKnowledgeTab("corrections");
-
- });
-
- document.getElementById("bulk-rerun").addEventListener("click", rerunSelectedEnrichment);
-
- document.querySelectorAll("[data-bulk-decision]").forEach((button) => {
-
- button.addEventListener("click", () => applyBulkDecision(button.dataset.bulkDecision));
-
- });
-
- document.getElementById("export-decisions").addEventListener("click", exportDecisions);
-
- document.getElementById("create-backup").addEventListener("click", createBackupSnapshot);
-
- document.getElementById("clear-reference").addEventListener("click", registerClearedReference);
-
- document.getElementById("llm-draft").addEventListener("click", (event) => {
-
- if (event.target.closest("#llm-draft-use")) pullLlmDraftIntoMemo();
-
- });
-
- document.getElementById("memo-presets").addEventListener("click", (event) => {
-
- if (event.target.closest("#memo-preset-save")) {
-
- void saveCurrentMemoAsPreset();
-
- return;
-
- }
-
- const chip = event.target.closest("[data-preset-id]");
-
- if (chip) applyMemoPreset(chip.dataset.presetId);
-
- });
-
- document.addEventListener("keydown", handleReviewShortcut);
-
- document.getElementById("emergency-disable").addEventListener("click", emergencyDisableExternalProviders);
-
-
-
- document.getElementById("app-main").addEventListener("click", (event) => {
-
- const providerButton = event.target.closest("[data-toggle-provider]");
-
- if (providerButton) {
-
- toggleProvider(providerButton.dataset.toggleProvider);
-
- return;
-
- }
-
-
-
- const retryButton = event.target.closest("[data-provider-retry]");
-
- if (retryButton) {
- // No provider-wide retry endpoint exists. Tell the operator honestly
- // instead of writing a fake "Provider called" audit event that never
- // re-ran anything (which falsely implied the failures were retried).
- showApiError("제공자 단위 '실패 재시도'는 아직 서버에 구현되지 않았습니다. 개별 케이스의 '재분석'을 사용하세요.");
- return;
- }
-
-
-
- const correctionButton = event.target.closest("[data-deactivate-correction]");
-
- if (correctionButton) {
-
- void deactivateCorrectionEntry(correctionButton.dataset.deactivateCorrection);
-
- return;
-
- }
-
-
-
- const editKnowledgeButton = event.target.closest("[data-edit-kb]");
- if (editKnowledgeButton) {
- editingKnowledgeEntryId = editingKnowledgeEntryId === editKnowledgeButton.dataset.editKb ? "" : editKnowledgeButton.dataset.editKb;
- renderKnowledgeBase();
- return;
- }
-
- const cancelEditKnowledgeButton = event.target.closest("[data-cancel-edit-kb]");
- if (cancelEditKnowledgeButton) {
- editingKnowledgeEntryId = "";
- renderKnowledgeBase();
- return;
- }
-
- const deactivateKnowledgeButton = event.target.closest("[data-deactivate-kb]");
- if (deactivateKnowledgeButton) {
- void deactivateKnowledgeEntry(deactivateKnowledgeButton.dataset.deactivateKb);
- return;
- }
-
- const reactivateKnowledgeButton = event.target.closest("[data-reactivate-kb]");
- if (reactivateKnowledgeButton) {
- void reactivateKnowledgeEntry(reactivateKnowledgeButton.dataset.reactivateKb);
- return;
- }
-
-
-
- const evidenceStatusButton = event.target.closest("[data-evidence-status]");
-
- if (evidenceStatusButton) {
-
- setEvidenceStatus(evidenceStatusButton.dataset.evidenceId, evidenceStatusButton.dataset.evidenceStatus);
-
- return;
-
- }
-
-
-
- const suggestedQueryButton = event.target.closest("[data-suggested-query]");
-
- if (suggestedQueryButton) {
-
- applySuggestedQuery(suggestedQueryButton);
-
- return;
-
- }
-
-
-
- const promoteWatchlistButton = event.target.closest("[data-promote-watchlist]");
- if (promoteWatchlistButton) {
-
- promoteWatchlistEntry(promoteWatchlistButton.dataset.promoteWatchlist);
-
- return;
-
- }
-
-
-
- const excludeWatchlistButton = event.target.closest("[data-exclude-watchlist]");
-
- if (excludeWatchlistButton) {
-
- excludeWatchlistEntry(excludeWatchlistButton.dataset.excludeWatchlist);
-
- return;
-
- }
-
-
-
- const promoteCandidateButton = event.target.closest("[data-promote-candidate]");
-
- if (promoteCandidateButton) {
-
- promoteCollectionCandidate(promoteCandidateButton.dataset.promoteCandidate);
-
- return;
-
- }
-
-
-
- const dismissCandidateButton = event.target.closest("[data-dismiss-candidate]");
-
- if (dismissCandidateButton) {
-
- void dismissCollectionCandidate(dismissCandidateButton.dataset.dismissCandidate);
-
- return;
-
- }
-
-
-
- const runSuggestedButton = event.target.closest("[data-run-suggested-query]");
- if (runSuggestedButton) {
- void executeSuggestedQueries([runSuggestedButton.dataset.runSuggestedQuery]);
- return;
- }
-
- const runAllSuggestedButton = event.target.closest("#run-all-suggested-queries");
- if (runAllSuggestedButton) {
- void executeSuggestedQueries(suggestedEvidenceQueries(getSelectedCase()));
- return;
- }
-
- const rerunQueryButton = event.target.closest("[data-rerun-query]");
-
- if (rerunQueryButton) {
-
- rerunHistoricalQuery(rerunQueryButton);
-
- }
-
- });
-
-}
-
-
-
-async function init() {
-
- const hashView = window.location.hash.replace("#", "");
-
- if (viewNames.has(hashView)) {
-
- state.currentView = hashView;
-
- } else if (hashView === "case" || hashView === "evidence") {
-
- state.currentView = "workbench";
-
- } else if (hashView === "corrections") {
-
- state.currentView = "knowledge";
-
- state.knowledgeTab = "corrections";
-
- }
-
- bindEvents();
-
- try {
-
- await refreshFromApi();
-
- } catch (errorValue) {
-
- clearRuntimeData();
-
- showApiError(`API 연결 실패: ${errorValue.message}`);
-
- renderAll();
-
- }
-
- switchView(state.currentView, false);
-
- switchWorkbenchTab(state.workbenchTab);
-
- switchKnowledgeTab(state.knowledgeTab);
-
-}
-
-
-
-document.addEventListener("DOMContentLoaded", () => {
-
- void init();
-
-});
-
-const __uiCopyCompatibility = {
- immediateMove: "바로 편입",
- reasonPresent: "근거 있음",
- noResult: "결과 없음",
- notRun: "미실행",
- evidenceUsed: "판단에 사용",
- falsePositive: "오탐",
- externalSearchTool: "외부 검색 tool",
- similarImage: "이미지 유사도",
- showMore: "자세히 보기",
- faceWebReason: "얼굴 영역 웹 근거",
- searchImageResult: "검색 이미지 결과",
- samePerson: "동일인 판정이 아닙니다",
- watchlistNote: "주의 후보 근거",
- evidenceUsedStatus: "사용",
- evidenceIgnoredStatus: "미사용",
- usedForJudgmentTuple: ["used_for_judgment", "사용"],
- ignoredTuple: ["ignored", "미사용"],
- pageRepresentativeImage: "페이지 대표 이미지",
- confidenceLabel: "신뢰도",
- queryStrategyByTitle: "구글 페이지 제목 기반",
- evidenceId: "근거 ID",
- queryStrategyBySubmission: "제출 제목/파일명 기반",
- sampleFingerprints: "샘플 지문",
-};
-
-window.addEventListener("hashchange", () => switchView(window.location.hash.replace("#", ""), false));
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/operator-gui/index.html b/web/operator-gui/index.html
index ad2f9bf..e6435fa 100644
--- a/web/operator-gui/index.html
+++ b/web/operator-gui/index.html
@@ -10,7 +10,28 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+