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}개 + +
+ ` + : ""; + 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(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(candidate.title || candidate.query)} 후보 이미지` + : `` + } +
+
+ ${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 ` + + + + + + ${escapeHtml(submission.title)} 썸네일 + + + +
+ + + + ${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(item.label)} + + ${escapeHtml(formatReason(item.label))} + +
+ + `, + + ) + + .join(""); + + document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || []) + .map( + (crop) => ` + + 얼굴 영역 ${escapeHtml(String(crop.index))} 크롭 + 얼굴 영역 ${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 ` +
+
+ 근거 보강 추천 + 추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다. +
+ +
+ ${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 ``; + +} + + + +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 ` -
-
- 근거 보강 추천 - 추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다. -
- -
- ${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 ` - - - - - - ${escapeHtml(submission.title)} 썸네일 - - - -
- - - - ${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(item.label)} - - ${escapeHtml(formatReason(item.label))} - -
- - `, - - ) - - .join(""); - - document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || []) - .map( - (crop) => ` - - 얼굴 영역 ${escapeHtml(String(crop.index))} 크롭 - 얼굴 영역 ${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}개 - -
- ` - : ""; - 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(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(candidate.title || candidate.query)} 후보 이미지` - : `` - } -
-
- ${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 ``; - -} - - - -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 @@ - + + + + + + + + + + + + + + + +