refactor: app.js 5,428줄을 관심사별 app-*.js 15개로 분할

단일 파일이 프로젝트 상한(800줄)의 6.8배였다. 분할은 순수 라인범위 추출이며
어떤 구문도 수정·재배치·재포맷하지 않았다 — 15개 파트의 연결 결과가 원본
app.js 와 바이트 단위로 동일함을 확인하고 나눴다(125,689B).

클래식 스크립트(모듈 아님)라 하나의 전역 스코프를 공유하므로 로드 순서가 곧
계약이다. 원본에서 로드 시점에 실행되는 최상위 구문은 파일 끝의 리스너 등록
2개뿐이었고(DOMContentLoaded/hashchange), 나머지 참조는 전부 함수 본문 안이라
순서만 지키면 의미가 보존된다. 그 순서를 index.html 에 명시하고 테스트로 고정:

- test_index_loads_every_app_part_exactly_once_in_declared_order
  각 파트가 정확히 1회, 선언 순서대로 로드되는지 + 디스크의 app-*.js 가
  전부 배선됐는지 + 구 app.js 가 남아있지 않은지
- test_app_parts_stay_within_the_file_size_budget (800줄 상한)

정적 스펙 31곳은 app.js 본문을 문자열로 검사하고 있었다. 이 스펙들의 관심사는
'어느 파일에 있는가'가 아니라 '콘솔에 그 동작이 있는가'이므로, 파트를 로드
순서대로 이어붙여 읽는 _read_app() 으로 바꿨다.

검증: Playwright E2E 6건이 실브라우저에서 통과(콘솔 에러 0 단언 포함).
운영 콘솔 실렌더 확인 — 큐/케이스 심사 정상, 스크립트 15개 전부 200,
새로고침 후 콘솔 메시지 0건.
This commit is contained in:
changukyu 2026-08-08 09:48:52 +09:00
parent bc14cd2177
commit fc4e04f65c
19 changed files with 5667 additions and 5467 deletions

View file

@ -8,7 +8,27 @@ ROOT = Path(__file__).resolve().parents[2]
APP_DIR = ROOT / "web" / "operator-gui" APP_DIR = ROOT / "web" / "operator-gui"
INDEX = APP_DIR / "index.html" INDEX = APP_DIR / "index.html"
STYLES = APP_DIR / "styles.css" 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" OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js"
SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js" SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js"
EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.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") 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(): def test_operator_workbench_files_exist():
assert INDEX.exists() assert INDEX.exists()
assert STYLES.exists() assert STYLES.exists()
assert APP_JS.exists() for path in APP_SCRIPTS:
assert path.exists(), path.name
assert OPERATOR_LABELS_JS.exists() assert OPERATOR_LABELS_JS.exists()
assert SUBMISSION_IMPORT_JS.exists() assert SUBMISSION_IMPORT_JS.exists()
assert EVIDENCE_GUIDANCE_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) text = _read(path)
for fragment in suspect_fragments: for fragment in suspect_fragments:
assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}" 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(): def test_static_app_models_core_review_operations():
script = _read(APP_JS) script = _read_app()
for state_name in [ for state_name in [
"submissions", "submissions",
@ -193,18 +223,56 @@ def test_static_app_models_core_review_operations():
assert f"function {operation}" in script 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'<script src="{name}" defer></script>'
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(): def test_operator_gui_extracts_submission_import_helpers_from_main_app():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
labels = _read(OPERATOR_LABELS_JS) labels = _read(OPERATOR_LABELS_JS)
helpers = _read(SUBMISSION_IMPORT_JS) helpers = _read(SUBMISSION_IMPORT_JS)
guidance = _read(EVIDENCE_GUIDANCE_JS) guidance = _read(EVIDENCE_GUIDANCE_JS)
search = _read(OPERATOR_SEARCH_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="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="submission-import.js"') < html.index(first_app_part)
assert html.index('src="evidence-guidance.js"') < html.index('src="app.js"') assert html.index('src="evidence-guidance.js"') < html.index(first_app_part)
assert html.index('src="operator-search.js"') < html.index('src="app.js"') assert html.index('src="operator-search.js"') < html.index(first_app_part)
assert "OperatorLabels" in labels assert "OperatorLabels" in labels
assert "riskLabels" in labels assert "riskLabels" in labels
assert "providerLabels" 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(): def test_operator_gui_does_not_boot_with_demo_review_data():
script = _read(APP_JS) script = _read_app()
for collection in [ for collection in [
"submissions", "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(): def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
html = _read(INDEX) html = _read(INDEX)
styles = _read(STYLES) styles = _read(STYLES)
script = _read(APP_JS) script = _read_app()
assert 'id="coverage-tabs"' in html assert 'id="coverage-tabs"' in html
assert 'id="search-coverage"' not 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(): def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns():
html = _read(INDEX) html = _read(INDEX)
styles = _read(STYLES) styles = _read(STYLES)
script = _read(APP_JS) script = _read_app()
assert "queue-grid" in html assert "queue-grid" in html
assert "queue-row" in script 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(): def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert 'id="workbench-view"' in html 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(): def test_case_decision_controls_float_while_reviewing_evidence():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "floating-decision-panel" in html 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(): def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "knowledge-main" in script 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(): def test_correction_history_lives_inside_knowledge_database_panel():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert 'data-knowledge-tab="collect"' in html 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(): def test_safety_rules_are_visible_in_ui_contract():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="decision-memo"' in html assert 'id="decision-memo"' in html
assert 'id="manual-query-provider"' 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(): 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 "provider.quota ? Math.min" in script
assert "configuredEnv" 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(): def test_query_history_rerun_preserves_original_search_provider():
script = _read(APP_JS) script = _read_app()
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
assert "data-rerun-provider" in script 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(): def test_operator_gui_exposes_submission_reload_action():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="submission-folder"' in html assert 'id="submission-folder"' in html
assert 'id="submission-image"' 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(): 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 [ 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(): def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
script = _read(APP_JS) script = _read_app()
assert "function selectedBulkSubmissionIds" in script assert "function selectedBulkSubmissionIds" in script
assert 'input[data-bulk-id]:checked' 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(): def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="knowledge-image"' in html assert 'id="knowledge-image"' in html
assert 'id="knowledge-image-name"' 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(): def test_operator_gui_exposes_keyword_candidate_collection_workflow():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
assert 'id="candidate-collection-form"' in html 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(): def test_google_custom_search_is_exposed_as_operator_text_query_choice():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
search = _read(OPERATOR_SEARCH_JS) search = _read(OPERATOR_SEARCH_JS)
labels = _read(OPERATOR_LABELS_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(): def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "provider-strip" in script 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(): 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 '["used_for_judgment", "사용"]' in script
assert '["ignored", "미사용"]' 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(): def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
for english_label in [ for english_label in [
"Image Rights Operator Console", "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(): def test_operator_gui_renders_search_result_links_and_thumbnails():
script = _read(APP_JS) script = _read_app()
assert "renderEvidenceLink" in script assert "renderEvidenceLink" in script
assert "thumbnailUrl" 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(): def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "renderEvidenceSummary" in script 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(): def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
guidance = _read(EVIDENCE_GUIDANCE_JS) guidance = _read(EVIDENCE_GUIDANCE_JS)
combined_script = script + guidance combined_script = script + guidance
styles = _read(STYLES) 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(): def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
script = _read(APP_JS) script = _read_app()
assert "faceCropSearch" in script assert "faceCropSearch" in script
assert "face_web" 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(): def test_operator_gui_exposes_evidence_status_and_watchlist_controls():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "/api/evidence/" in script assert "/api/evidence/" in script
@ -796,7 +864,7 @@ def test_visual_assets_are_referenced_for_review_images():
assets_dir = APP_DIR / "assets" assets_dir = APP_DIR / "assets"
assert (assets_dir / "case-portrait.svg").exists() 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 [ for orphan_asset in [
"case-character.svg", "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(): def test_suggested_queries_support_one_click_and_batch_execution():
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert "executeSuggestedQueries" in script 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(): def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
html = _read(INDEX) 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] registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
assert 'id="knowledge-search"' in registered_panel 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(): def test_workbench_shows_detected_face_crop_strip():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
assert 'id="face-crop-strip"' in html assert 'id="face-crop-strip"' in html
assert "faceCrops" in script 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(): def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
html = _read(INDEX) html = _read(INDEX)
script = _read(APP_JS) script = _read_app()
styles = _read(STYLES) styles = _read(STYLES)
assert 'id="rerun-diff-summary"' in html assert 'id="rerun-diff-summary"' in html

View file

@ -1179,7 +1179,12 @@ def test_http_server_runs_auto_search_for_submission(tmp_path: Path):
def test_operator_gui_uses_api_bootstrap(): 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 "/api/bootstrap" in app_js
assert "refreshFromApi" in app_js assert "refreshFromApi" in app_js

View file

@ -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);
}
}

264
web/operator-gui/app-api.js Normal file
View file

@ -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();
}

View file

@ -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));

View file

@ -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;
}
}

View file

@ -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) =>
`<button type="button" class="memo-preset-chip" data-preset-id="${escapeHtml(preset.id)}" title="${escapeHtml(preset.body)}">${escapeHtml(preset.label)}</button>`,
)
.join("");
target.innerHTML = `${chips}<button type="button" class="memo-preset-chip memo-preset-add" id="memo-preset-save">+ 현재 메모 저장</button>`;
}
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 = `
<div class="llm-draft-head">
<span class="llm-draft-tag">내부 요약(초안)</span>
<span class="muted small">${escapeHtml(sourceNote)} · 최종 판정은 운영자</span>
</div>
<p class="llm-draft-body">${escapeHtml(currentLlmDraftText)}</p>
<button type="button" class="llm-draft-use" id="llm-draft-use">메모 초안으로 넣기</button>
`;
}
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();
}

View file

@ -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 `
<button
class="coverage-tab ${tone} ${active ? "active" : ""}"
type="button"
data-coverage-filter="${escapeHtml(filter)}"
title="${escapeHtml(title || label)}"
aria-pressed="${active ? "true" : "false"}"
>
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
</button>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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 `<option value="${escapeHtml(provider.id)}" ${unavailable ? "disabled" : ""} title="${escapeHtml(reason)}">${escapeHtml(label)}</option>`;
})
.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 || "-";
}

View file

@ -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 `<button type="button" class="duplicate-badge${tone}" data-duplicate-group="${escapeHtml(submission.id)}" title="${escapeHtml(`${priorText}${conflictNote}`)}">동일 이미지 ${group.size}건${conflictNote}</button>`;
}
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;
}
}

View file

@ -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] : [];
}

View file

@ -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
? `
<details class="rerun-removed">
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}개</summary>
<ul>
${removedSummaries
.map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.reason || ""))}</li>`)
.join("")}
</ul>
</details>
`
: "";
return `
<section class="rerun-diff-panel" aria-label="재분석 변경 요약">
<strong>재분석 ${escapeHtml(diff.at || "")}</strong>
<span>점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})</span>
<span>신규 증거 ${(diff.addedEvidenceIds || []).length}개</span>
${removedBlock}
</section>
`;
}
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 `
<section class="evidence-summary-board" aria-label="판단 요약">
<div class="summary-lead">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
<div>
<strong>${escapeHtml(primaryJudgment)}</strong>
<span>${searchCount}개 검색 근거 · 전체 ${submission.evidence.length}개 증거</span>
</div>
</div>
<div class="summary-stat-grid">
${summaryCards
.map(
([label, value]) => `
<div class="summary-stat ${value ? "has-signal" : ""}">
<span>${escapeHtml(label)}</span>
<strong>${value}</strong>
</div>
`,
)
.join("")}
</div>
</section>
`;
}
function renderEvidenceGroups(submission) {
if (!submission.evidence.length) {
return `<div class="empty-state">아직 분석 증거가 없습니다. 외부 검색 도구 상태를 확인하고 증거 재수집을 실행하세요.</div>`;
}
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 `
<section class="evidence-group evidence-group-${escapeHtml(group.tone)}" aria-label="${escapeHtml(group.label)} 증거">
<div class="evidence-group-head">
<div>
<h3>${escapeHtml(group.label)}</h3>
<span>${escapeHtml(group.description)}</span>
</div>
<strong>${items.length}개</strong>
</div>
<div class="evidence-card-grid">
${topItems.map(renderEvidenceRow).join("")}
</div>
${
detailItems.length
? `
<details class="evidence-details">
<summary>자세히 보기 · ${detailItems.length}개</summary>
<div class="evidence-card-grid evidence-card-grid-detail">
${detailItems.map(renderEvidenceRow).join("")}
</div>
</details>
`
: ""
}
</section>
`;
}
function renderEvidenceRow(evidence) {
const isNewFromRerun = activeRerunAddedIds.has(evidence.id);
const newChip = isNewFromRerun ? `<span class="evidence-new-chip">신규</span>` : "";
const sourceEvidenceIds = evidence.sourceEvidenceIds || [];
const sourceIds = sourceEvidenceIds.length
? `근거 ID: ${sourceEvidenceIds.join(", ")}`
: "근거 ID 없음";
const contribution = evidence.contributed ? "점수 반영" : "참고";
const normalizedOperatorStatus = normalizeEvidenceOperatorStatus(evidence.operatorStatus);
const operatorStatus = normalizedOperatorStatus
? `<span class="evidence-status-chip">${escapeHtml(formatEvidenceStatus(normalizedOperatorStatus))}</span>`
: "";
const confidence = typeof evidence.confidence === "number" ? evidence.confidence.toFixed(2) : evidence.confidence;
const matchType = evidence.matchType ? `<span>${escapeHtml(formatMatchType(evidence.matchType))}</span>` : "";
const pageTitle = evidence.pageTitle ? `<span>${escapeHtml(evidence.pageTitle)}</span>` : "";
const faceCropMeta = evidence.faceCropSearch
? `<span>얼굴 영역 ${escapeHtml(evidence.cropIndex || "")}</span><span>${escapeHtml(evidence.privacyNote || "동일 인물 판정은 아닙니다")}</span>`
: "";
const watchlistMeta =
evidence.knowledgeEntryStatus === "watchlist"
? `<span>주의 후보: ${escapeHtml(evidence.knowledgeEntryName || evidence.knowledgeEntryId || "-")}</span><span>원 케이스 ${escapeHtml(evidence.sourceSubmissionId || "-")}</span>`
: "";
const providerScore =
evidence.providerScore !== undefined && evidence.providerScore !== null && evidence.providerScore !== "" ? `<span>검색 도구 점수 ${escapeHtml(evidence.providerScore)}</span>` : "";
const similarity =
evidence.similarity !== undefined && evidence.similarity !== ""
? `<span>이미지 유사도 ${escapeHtml(Number(evidence.similarity).toFixed(2))}</span>`
: "";
const queryStrategy = evidence.queryStrategy ? `<span>${escapeHtml(formatQueryStrategy(evidence.queryStrategy))}</span>` : "";
const searchTypeLabels = { image: "이미지 검색", blog: "블로그 페이지", web: "웹문서 페이지" };
const searchType = evidence.searchType ? `<span>${escapeHtml(searchTypeLabels[evidence.searchType] || evidence.searchType)}</span>` : "";
const hasPreview = Boolean(evidence.thumbnailUrl || evidence.imageUrl);
const preview = renderEvidencePreview(evidence);
const link = renderEvidenceLink(evidence);
const statusButtons = [
["used_for_judgment", "사용"],
["ignored", "미사용"],
]
.map(
([status, label]) => `
<button class="row-action ${normalizedOperatorStatus === status ? "active" : ""}" type="button" data-evidence-status="${status}" data-evidence-id="${escapeHtml(evidence.id)}">
${label}
</button>
`,
)
.join("");
return `
<article class="evidence-row ${hasPreview ? "" : "no-preview"} ${isNewFromRerun ? "evidence-row-new" : ""}" data-evidence-id="${escapeHtml(evidence.id)}">
${preview}
<div class="evidence-main">
<div class="evidence-title">
<span class="source-chip ${sourceClass(evidence.source)}">${escapeHtml(sourceLabels[evidence.source] || evidence.source)}</span>
${newChip}
<span>${escapeHtml(formatEvidenceTitle(evidence))}</span>
</div>
<div class="evidence-meta">
<span>신뢰도 ${escapeHtml(confidence)}</span>
<span>${escapeHtml(contribution)}</span>
${operatorStatus}
${matchType}
${faceCropMeta}
${watchlistMeta}
${providerScore}
${similarity}
${queryStrategy}
${searchType}
${pageTitle}
<span>${escapeHtml(evidence.query || "쿼리 없음")}</span>
<span>${escapeHtml(formatDomain(evidence.domain))}</span>
<span>${escapeHtml(evidence.retrievedAt)}</span>
<span>${escapeHtml(sourceIds)}</span>
</div>
${link}
</div>
<div class="evidence-actions">
${statusButtons}
</div>
</article>
`;
}
function renderEvidencePreview(evidence) {
const previewUrl = evidence.thumbnailUrl || evidence.imageUrl;
const targetUrl = evidence.imageUrl || evidence.url || previewUrl;
if (!previewUrl) return `<div class="evidence-preview evidence-preview-empty" aria-hidden="true"></div>`;
return `
<a class="evidence-preview" href="${escapeHtml(safeUrl(targetUrl))}" target="_blank" rel="noreferrer" aria-label="검색 이미지 열기">
<img src="${escapeHtml(safeUrl(previewUrl))}" alt="" onerror="this.style.display='none'">
</a>
`;
}
function renderEvidenceLink(evidence) {
const url = evidence.url || evidence.imageUrl || "";
if (!url) return "";
const label = evidence.pageTitle || evidence.url || evidence.imageUrl;
return `
<a class="evidence-link" href="${escapeHtml(safeUrl(url))}" target="_blank" rel="noreferrer">
${escapeHtml(label)}
</a>
`;
}
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 = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
results.innerHTML = `<div class="empty-state">API 연결 후 검색 근거를 확인할 수 있습니다.</div>`;
return;
}
document.getElementById("query-case-label").textContent = submission.id;
queryHistory.innerHTML = submission.queryHistory.length
? submission.queryHistory
.map(
(query) => `
<article class="query-row">
<div class="row-title">
<span class="source-chip ${sourceClass(query.provider)}">${escapeHtml(providerLabels[query.provider] || query.provider)}</span>
<span>${escapeHtml(query.query)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(formatQueryStatus(query.status))}</span>
${query.strategy ? `<span>${escapeHtml(formatQueryStrategy(query.strategy))}</span>` : ""}
${query.source ? `<span>출처 ${escapeHtml(query.source)}</span>` : ""}
<span>${escapeHtml(query.timestamp)}</span>
<span>${query.count}건</span>
</div>
<button class="row-action" type="button" data-rerun-query="${escapeHtml(query.query)}" data-rerun-provider="${escapeHtml(query.provider || "")}">쿼리 재실행</button>
</article>
`,
)
.join("")
: `<div class="empty-state">아직 실행한 검색 쿼리가 없습니다.</div>`;
activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
const searchableEvidence = searchableEvidenceItems(submission);
results.innerHTML = searchableEvidence.length
? renderEvidenceGroups({ ...submission, evidence: searchableEvidence })
: `<div class="empty-state">현재 케이스에는 검색 결과가 없습니다.</div>`;
}

View file

@ -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"
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
: "";
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
const lifecycleActions =
entryStatus === "watchlist"
? `
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
`
: entryStatus === "excluded"
? ""
: entry.active
? `<button class="row-action danger" type="button" data-deactivate-kb="${escapeHtml(entry.id)}">비활성</button>`
: `<button class="row-action" type="button" data-reactivate-kb="${escapeHtml(entry.id)}">재활성</button>`;
const editForm =
editingKnowledgeEntryId === entry.id
? `
<form class="knowledge-edit-form" data-knowledge-edit-form="${escapeHtml(entry.id)}">
<label>
<span>별칭</span>
<input name="aliases" type="text" value="${escapeHtml(aliases.join(", "))}">
</label>
<label>
<span>검색 키워드</span>
<input name="keywords" type="text" value="${escapeHtml(keywords.join(", "))}">
</label>
<label class="wide-field">
<span>정책 메모</span>
<textarea name="memo" rows="3">${escapeHtml(entry.memo || "")}</textarea>
</label>
<div class="knowledge-edit-actions">
<button class="primary-action" type="submit">저장</button>
<button class="secondary-action" type="button" data-cancel-edit-kb="${escapeHtml(entry.id)}">취소</button>
</div>
</form>
`
: "";
return `
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
${
entry.imageAsset
? `<img class="knowledge-thumb" src="${escapeHtml(entry.imageAsset)}" alt="${escapeHtml(entry.name)} 참조 이미지">`
: `<div class="knowledge-thumb empty" aria-hidden="true"></div>`
}
<div class="knowledge-main">
<div class="row-title knowledge-title">
<span>${escapeHtml(entry.name)}</span>
</div>
<div class="knowledge-detail-line">
<div class="knowledge-chip-row">
<span class="provenance-chip ${escapeHtml(entry.provenance)}">${escapeHtml(formatProvenance(entry.provenance))}</span>
<span class="watchlist-chip ${escapeHtml(entryStatus)}">${escapeHtml(formatKnowledgeStatus(entryStatus))}</span>
</div>
<div class="row-meta knowledge-meta">
<span>${escapeHtml(formatKnowledgeType(entry.type))}</span>
<span>별칭: ${escapeHtml(aliases.join(", ") || "-")}</span>
<span>키워드: ${escapeHtml(keywords.join(", ") || "-")}</span>
<span>샘플 지문 ${(entry.sampleFingerprints || []).length}</span>
${statusMeta}
${memoInline}
</div>
</div>
${memoBlock}
${editForm}
</div>
<div class="knowledge-actions">
<button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
${lifecycleActions}
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
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 `
<article class="candidate-card ${promoted ? "promoted" : ""}">
<label class="candidate-select">
<input
type="checkbox"
data-collection-candidate-id="${escapeHtml(candidate.id)}"
${canPromote ? "" : "disabled"}
>
<span class="sr-only">${escapeHtml(candidate.title || candidate.query)} 선택</span>
</label>
${
candidate.imageAsset
? `<img class="candidate-thumb" src="${escapeHtml(candidate.imageAsset)}" alt="${escapeHtml(candidate.title || candidate.query)} 후보 이미지">`
: `<div class="candidate-thumb empty" aria-hidden="true"></div>`
}
<div class="candidate-main">
<div class="row-title">
<span class="source-chip ${sourceClass(candidate.provider)}">${escapeHtml(providerLabels[candidate.provider] || candidate.provider || "검색")}</span>
<span>${escapeHtml(candidate.title || candidate.query)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(candidate.query)}</span>
<span>${escapeHtml(formatCandidateSourceType(candidate.sourceCandidateType))}</span>
<span>순위 ${escapeHtml(candidate.rank || "-")}</span>
<span>${escapeHtml(formatCandidateStatus(candidate.status))}</span>
</div>
${
candidate.sourceUrl
? `<a class="evidence-link" href="${escapeHtml(safeUrl(candidate.sourceUrl))}" target="_blank" rel="noreferrer">출처 열기</a>`
: ""
}
</div>
<div class="candidate-actions">
<button class="row-action" type="button" data-promote-candidate="${escapeHtml(candidate.id)}" ${canPromote ? "" : "disabled"}>
${promoted ? "반영됨" : "기준 DB 반영"}
</button>
<button class="row-action secondary-action" type="button" data-dismiss-candidate="${escapeHtml(candidate.id)}">무시</button>
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">검색어와 제공자를 선택하면 기준 DB 후보가 표시됩니다.</div>`;
}
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) => `
<article class="correction-row">
<div>
<div class="row-title">
<span class="risk-badge risk-failed">보정</span>
<span>${escapeHtml(correction.decisionId)} · ${escapeHtml(correction.submissionId)}</span>
</div>
<div class="row-meta">
<span>${escapeHtml(correction.previous)} -> ${escapeHtml(correction.current)}</span>
<span>${escapeHtml(correction.operator)}</span>
<span>${escapeHtml(correction.timestamp)}</span>
</div>
<p class="muted small">${escapeHtml(correction.reason)}</p>
<div class="chip-row">
${(correction.derivedEntries || [])
.map(
(entry) =>
`<span class="provider-chip ${entry.active ? "failed" : "disabled"}">${escapeHtml(entry.name)} · ${entry.active ? "활성" : "비활성"}</span>`,
)
.join("")}
</div>
</div>
<button class="row-action" type="button" data-deactivate-correction="${escapeHtml(correction.id)}">파생 항목 비활성</button>
</article>
`,
)
.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 `<span class="muted small">환경 변수 확인됨: ${escapeHtml(requiredEnv.join(", "))}</span>`;
}
return `<span class="muted small">누락된 환경 변수: ${escapeHtml(missing.join(", "))}</span>`;
}
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 `
<article class="provider-row">
<div class="provider-summary">
<div class="row-title">
<span class="status-dot ${provider.enabled ? "ok" : "bad"}" aria-hidden="true"></span>
<span>${escapeHtml(providerName)}</span>
</div>
<div class="row-meta">
<span>${provider.enabled ? "활성" : "중지"}</span>
<span>${escapeHtml(formatReason(provider.compliance))}</span>
<span>최근 성공: ${escapeHtml(formatReason(provider.lastSuccess))}</span>
<span>최근 실패: ${escapeHtml(formatReason(provider.lastFailure))}</span>
</div>
<span class="muted small">${escapeHtml(formatReason(provider.boundary))}</span>
${envStatus}
</div>
<div>
<div class="row-meta"><span>${provider.usage} / ${provider.quota == null ? "무제한" : provider.quota}</span><span>${percent}%</span></div>
<div class="quota-meter" aria-label="${escapeHtml(providerName)} 사용량 ${percent}%"><span style="width: ${percent}%"></span></div>
</div>
<div class="provider-actions">
<button type="button" data-toggle-provider="${escapeHtml(provider.id)}">${provider.enabled ? "중지" : "활성"}</button>
<button type="button" data-provider-retry="${escapeHtml(provider.id)}">실패 재시도</button>
</div>
</article>
`;
})
.join("");
}
function renderAuditLog() {
document.getElementById("audit-body").innerHTML = auditEvents
.map(
(event) => `
<tr>
<td>${escapeHtml(event.timestamp)}</td>
<td>${escapeHtml(event.actor)}</td>
<td>${escapeHtml(formatReason(event.event))}</td>
<td>${escapeHtml(formatReason(event.object))}</td>
<td>${escapeHtml(formatReason(event.change))}</td>
</tr>
`,
)
.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 = `
<span class="status-dot ${className}" aria-hidden="true"></span>
<span>외부 검색 tool ${visibleProviders.filter((provider) => provider.enabled).length}/${visibleProviders.length} 활성 · 실패 ${failed}</span>
`;
}
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 반영";
});
}

View file

@ -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
? `
<span class="status-dot warn" aria-hidden="true"></span>
<span>${escapeHtml(state.apiError)}</span>
`
: `
<span class="status-dot ${rows.some((item) => item.riskBand === "failed") ? "warn" : "ok"}" aria-hidden="true"></span>
<span>${rows.length}건 / 고위험 ${submissions.filter((item) => item.riskBand === "high").length}건${queueMeta}</span>
`;
if (!rows.length) {
body.innerHTML = `
<tr>
<td colspan="9">
<div class="empty-state">현재 필터에 맞는 심사 건이 없습니다.</div>
</td>
</tr>
`;
return;
}
body.innerHTML = rows
.map((submission) => {
const providerHtml = visibleProviderStateEntries(submission.providerState)
.map(
([provider, status]) => {
const fullLabel = `${providerLabels[provider] || provider} ${formatProviderStatus(status)}`;
return `<span class="provider-chip queue-provider-chip ${providerClass(status)}" title="${escapeHtml(fullLabel)}">${escapeHtml(formatQueueProviderStatus(provider, status))}</span>`;
},
)
.join("");
const reasonHtml = submission.reasons
.slice(0, 2)
.map((reason) => `<span title="${escapeHtml(formatReason(reason))}">${escapeHtml(formatReason(reason))}</span>`)
.join("");
const submittedTime = formatQueueTimestamp(submission.submittedAt);
return `
<tr class="queue-row ${submission.id === state.selectedCaseId ? "selected-row" : ""}" data-row-case="${escapeHtml(submission.id)}">
<td><input type="checkbox" aria-label="${escapeHtml(submission.id)} 선택" data-bulk-id="${escapeHtml(submission.id)}"></td>
<td><img class="thumb" src="${escapeHtml(submission.asset)}" alt="${escapeHtml(submission.title)} 썸네일"></td>
<td>
<div class="queue-submission-cell">
<button class="row-action queue-case-button" type="button" data-select-case="${escapeHtml(submission.id)}" title="${escapeHtml(submission.id)}">
<span class="submission-id">${escapeHtml(submission.id)}</span>
</button>
${duplicateBadgeHtml(submission)}
</div>
</td>
<td>
<div class="score-stack queue-risk-cell" title="${escapeHtml(submission.lastAnalysis)}">
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]} ${submission.riskScore}</span>
</div>
</td>
<td><div class="reason-cell">${reasonHtml}</div></td>
<td><div class="provider-strip queue-provider-strip">${providerHtml}</div></td>
<td>${escapeHtml(submission.applicantStatus)}</td>
<td><span class="decision-badge">${decisionLabels[submission.decisionStatus]}</span></td>
<td>
<div class="queue-time-cell" title="${escapeHtml(submission.fileFacts.submitted)}">
<strong>${escapeHtml(submittedTime)}</strong>
</div>
</td>
</tr>
`;
})
.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 = `
<span class="status-dot ${tone}" aria-hidden="true"></span>
<span>${escapeHtml(state.importStatus.message)}</span>
`;
}
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 = `<div class="empty-state">표시할 케이스가 없습니다.</div>`;
document.getElementById("evidence-groups").innerHTML = `<div class="empty-state">API 연결 실패 또는 제출 이미지 없음 상태입니다.</div>`;
document.getElementById("recommendation-box").innerHTML = `<span class="muted">실제 API 데이터가 로드될 때까지 판정하지 않습니다.</span>`;
document.getElementById("derived-preview").innerHTML = `<span class="muted">자동 후보 생성 없음</span>`;
// 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]) => `<div class="fact-item"><span>${escapeHtml(formatFactLabel(label))}</span><strong>${escapeHtml(value)}</strong></div>`)
.join("");
document.getElementById("similar-strip").innerHTML = submission.similar
.map(
(item) => `
<div class="similar-item">
<img src="${escapeHtml(safeUrl(item.asset))}" alt="${escapeHtml(item.label)}">
<span>${escapeHtml(formatReason(item.label))}</span>
</div>
`,
)
.join("");
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
<a class="similar-item face-crop-item face-crop-link" href="${escapeHtml(safeUrl(crop.url))}" target="_blank" rel="noreferrer" aria-label="얼굴 영역 ${escapeHtml(String(crop.index))} 크게 보기">
<img src="${escapeHtml(safeUrl(crop.url))}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정은 아닙니다</span>
</a>
`,
)
.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 = `
<span class="risk-badge ${riskClass(submission.riskBand)}">${riskLabels[submission.riskBand]}</span>
<strong>${escapeHtml(submission.recommendation.label)}</strong>
<span class="muted">${escapeHtml(formatReason(submission.recommendation.detail))}</span>
<span class="muted small">현재 운영 결정: ${decisionLabels[submission.decisionStatus]}</span>
`;
document.getElementById("derived-preview").innerHTML = `
<strong>${submission.derivedPreview.automatic ? "자동 후보 생성 예정" : "자동 이력 없음"}</strong>
<span>${escapeHtml(submission.derivedPreview.entryName)}</span>
<span class="muted small">${escapeHtml(submission.derivedPreview.effect)}</span>
`;
}

View file

@ -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
? `<div class="inline-status" id="suggested-query-run-result">${escapeHtml(suggestedQueryRunSummary.message)}</div>`
: "";
if (!evidenceNeedsFollowup(submission)) return summary;
const queries = suggestedEvidenceQueries(submission);
const reasons = evidenceFollowupReasons(submission);
if (!queries.length) return summary;
return `
<section class="evidence-next-action-panel" aria-label="근거 보강 추천">
<div>
<strong>근거 보강 추천</strong>
<span>추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.</span>
</div>
<ul class="evidence-followup-reasons">
${reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
</ul>
<div class="suggested-query-list">
${queries
.map(
(query) => `
<span class="suggested-query-item">
<button class="row-action" type="button" data-suggested-query="${escapeHtml(query)}" data-suggested-provider="naver">
${escapeHtml(query)}
</button>
<button class="row-action" type="button" data-run-suggested-query="${escapeHtml(query)}">바로 실행</button>
</span>
`,
)
.join("")}
</div>
<div class="suggested-query-actions">
<button class="row-action" type="button" id="run-all-suggested-queries">모두 실행</button>
</div>
<div id="suggested-query-run-status" class="inline-status" aria-live="polite"></div>
${summary}
</section>
`;
}
function applySuggestedQuery(button) {
const queryInput = document.getElementById("manual-query");
const providerSelect = document.getElementById("manual-query-provider");
const status = document.getElementById("manual-query-status");
const provider = normalizeManualSearchProvider(button.dataset.suggestedProvider);
queryInput.value = button.dataset.suggestedQuery || "";
providerSelect.value = provider;
switchWorkbenchTab("queries");
queryInput.focus();
if (status) status.textContent = "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요.";
}
let isSuggestedQueryRunActive = false;
async function executeSuggestedQueries(queries) {
if (isSuggestedQueryRunActive) return;
const submission = getSelectedCase();
if (!submission || !queries.length) return;
const statusTarget = document.getElementById("suggested-query-run-status");
const naver = providers.find((provider) => provider.id === "naver");
if (!naver || !naver.enabled) {
if (statusTarget) statusTarget.textContent = `${providerLabels.naver} 외부 검색 tool이 비활성입니다.`;
return;
}
isSuggestedQueryRunActive = true;
try {
const results = [];
for (const [index, query] of queries.entries()) {
if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`;
try {
const review = await apiJson("/api/search/manual", {
method: "POST",
body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }),
});
// queryHistory.count is the number of evidence rows the search produced,
// which includes the skip/failure/"no results" markers — so a query that
// was refused still reported count>=1 and was announced as "완료(근거 1건)"
// while the skip branch below could never run. Count real evidence here
// and let a failure row speak for itself.
const matching = (review.evidence || []).filter((item) => item.query === query);
const skipped = matching.find((item) => item.source === "failure");
const resultCount = matching.filter(
(item) =>
item.source !== "failure" &&
!/returned no results/i.test(String(item.title || "")),
).length;
if (skipped) {
results.push(`"${query}" ${formatReason(skipped.title || "건너뜀")}`);
} else if (resultCount === 0) {
results.push(`"${query}" 결과 없음`);
} else {
results.push(`"${query}" 완료(근거 ${resultCount}건)`);
}
} catch (errorValue) {
results.push(`"${query}" 실패: ${errorValue.message}`);
}
}
suggestedQueryRunSummary = {
caseId: submission.id,
message: `추천 쿼리 실행 — ${results.join(" · ")}`,
};
try {
await refreshFromApi();
} catch (refreshError) {
// Don't let a refresh failure become an unhandled rejection that leaves
// the status stuck at "실행 중…". Re-resolve the element (a render may have
// replaced it) and report honestly alongside the collected results.
const liveStatus = document.getElementById("suggested-query-run-status");
if (liveStatus) {
liveStatus.textContent = `새로고침 실패: ${refreshError.message} · 실행 결과: ${results.join(" · ")}`;
}
}
} finally {
isSuggestedQueryRunActive = false;
}
}
function filteredSubmissions() {
const query = state.filters.query.trim().toLowerCase();
const filtered = submissions.filter((submission) => {
const riskOk = state.filters.risk === "all" || submission.riskBand === state.filters.risk;
const sourceOk = sourceMatches(submission, state.filters.source);
const decisionOk = state.filters.decision === "all" || submission.decisionStatus === state.filters.decision;
const queryOk =
!query ||
submission.id.toLowerCase().includes(query) ||
submission.title.toLowerCase().includes(query) ||
submission.reasons.join(" ").toLowerCase().includes(query);
return riskOk && sourceOk && decisionOk && queryOk;
});
return filtered.sort((left, right) => {
if (state.filters.sort === "newest") return right.submittedEpoch - left.submittedEpoch;
if (state.filters.sort === "oldest") return left.submittedEpoch - right.submittedEpoch;
if (state.filters.sort === "analysis_failure") {
return Number(right.riskBand === "failed") - Number(left.riskBand === "failed");
}
if (state.filters.sort === "provider_failure") {
return Number(sourceMatches(right, "failure")) - Number(sourceMatches(left, "failure"));
}
return right.riskScore - left.riskScore;
});
}

View file

@ -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 <img>/media requests that cannot carry a header.
let authToken = "";
let pendingAuthPrompt = null;

View file

@ -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 = `<div class="empty-state">통계를 불러오는 중…</div>`;
try {
const stats = await apiJson("/api/stats/decisions");
renderDecisionStats(stats);
} catch (errorValue) {
body.innerHTML = `<div class="empty-state">통계를 불러오지 못했습니다: ${escapeHtml(errorValue.message)}</div>`;
}
}
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 = `
<div class="stat-tiles">
<article class="stat-tile tone-${overTone}">
<span class="stat-tile-label">과탐 — 고위험인데 승인</span>
<strong class="stat-tile-value">${formatPercent(cal.highBandApprovalRate)}</strong>
<span class="stat-tile-sub">고위험 판정 ${cal.highBandDecided || 0}건 기준</span>
</article>
<article class="stat-tile tone-${underTone}">
<span class="stat-tile-label">미탐 — 저위험인데 반려</span>
<strong class="stat-tile-value">${formatPercent(cal.lowBandRejectionRate)}</strong>
<span class="stat-tile-sub">저위험 판정 ${cal.lowBandDecided || 0}건 기준</span>
</article>
<article class="stat-tile">
<span class="stat-tile-label">미심사 백로그</span>
<strong class="stat-tile-value">${backlog.unreviewed}</strong>
<span class="stat-tile-sub">전체 ${backlog.total}건 중</span>
</article>
<article class="stat-tile">
<span class="stat-tile-label">누적 판정</span>
<strong class="stat-tile-value">${throughput.totalDecisions || 0}</strong>
<span class="stat-tile-sub">감사 로그 기준</span>
</article>
</div>
`;
body.innerHTML = `
${tiles}
<div class="stats-columns">
<section class="stats-card">
<h2>밴드 × 판정 매트릭스</h2>
<p class="muted small">각 위험 밴드가 실제로 어떻게 판정됐는지. 대각선을 벗어난 값이 보정 오차입니다.</p>
${renderStatsMatrix(stats)}
</section>
<section class="stats-card">
<h2>최근 결정 추이</h2>
<p class="muted small">최근 14일 일별 판정 건수.</p>
${renderThroughputBars(throughput.byDay || [])}
</section>
</div>
`;
}
function renderStatsMatrix(stats) {
const bands = stats.bands || [];
const decisions = stats.decisions || [];
const matrix = stats.matrix || {};
const head = decisions.map((d) => `<th scope="col">${escapeHtml(DECISION_STAT_LABELS[d] || d)}</th>`).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 `<td class="matrix-cell${strong}">${value}</td>`;
})
.join("");
return `<tr><th scope="row"><span class="risk-badge ${riskClass(band)}">${escapeHtml(BAND_STAT_LABELS[band] || band)}</span></th>${cells}<td class="matrix-total">${total}</td></tr>`;
})
.join("");
return `
<div class="table-shell">
<table class="stats-matrix">
<thead><tr><th scope="col">밴드 \\ 판정</th>${head}<th scope="col">합계</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function renderThroughputBars(byDay) {
if (!byDay.length) {
return `<div class="empty-state">아직 기록된 판정이 없습니다.</div>`;
}
const max = Math.max(...byDay.map((entry) => entry.count), 1);
const bars = byDay
.map((entry) => {
const height = Math.round((entry.count / max) * 100);
return `
<div class="throughput-bar" title="${escapeHtml(entry.day)} · ${entry.count}건">
<div class="throughput-bar-fill" style="height:${height}%"></div>
<span class="throughput-bar-count">${entry.count}</span>
<span class="throughput-bar-day">${escapeHtml(entry.day.slice(5))}</span>
</div>
`;
})
.join("");
return `<div class="throughput-chart" role="img" aria-label="일별 판정 건수">${bars}</div>`;
}
function formatPercent(rate) {
return `${Math.round((Number(rate) || 0) * 100)}%`;
}

View file

@ -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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,28 @@
<script src="submission-import.js" defer></script> <script src="submission-import.js" defer></script>
<script src="evidence-guidance.js" defer></script> <script src="evidence-guidance.js" defer></script>
<script src="operator-search.js" defer></script> <script src="operator-search.js" defer></script>
<script src="app.js" defer></script> <!--
The former single app.js, split by concern. These are classic scripts
sharing one global scope, so THIS ORDER IS LOAD-BEARING: state before the
code that reads it, and app-bootstrap.js last because it is the only part
that runs anything at load (event binding + DOMContentLoaded). A static
test pins this exact sequence.
-->
<script src="app-state.js" defer></script>
<script src="app-api.js" defer></script>
<script src="app-format.js" defer></script>
<script src="app-selectors.js" defer></script>
<script src="app-render-queue.js" defer></script>
<script src="app-render-evidence.js" defer></script>
<script src="app-render-panels.js" defer></script>
<script src="app-navigation.js" defer></script>
<script src="app-keyboard-batch.js" defer></script>
<script src="app-decision-tools.js" defer></script>
<script src="app-stats.js" defer></script>
<script src="app-submissions.js" defer></script>
<script src="app-collections.js" defer></script>
<script src="app-admin.js" defer></script>
<script src="app-bootstrap.js" defer></script>
</head> </head>
<body data-internal-only="true"> <body data-internal-only="true">
<div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title"> <div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title">