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:
parent
bc14cd2177
commit
fc4e04f65c
19 changed files with 5667 additions and 5467 deletions
|
|
@ -8,7 +8,27 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||
APP_DIR = ROOT / "web" / "operator-gui"
|
||||
INDEX = APP_DIR / "index.html"
|
||||
STYLES = APP_DIR / "styles.css"
|
||||
APP_JS = APP_DIR / "app.js"
|
||||
# The former single app.js, split by concern. Order is load-bearing (classic
|
||||
# scripts sharing one global scope), so this list doubles as the expectation the
|
||||
# index.html ordering test asserts against.
|
||||
APP_SCRIPT_NAMES = [
|
||||
"app-state.js",
|
||||
"app-api.js",
|
||||
"app-format.js",
|
||||
"app-selectors.js",
|
||||
"app-render-queue.js",
|
||||
"app-render-evidence.js",
|
||||
"app-render-panels.js",
|
||||
"app-navigation.js",
|
||||
"app-keyboard-batch.js",
|
||||
"app-decision-tools.js",
|
||||
"app-stats.js",
|
||||
"app-submissions.js",
|
||||
"app-collections.js",
|
||||
"app-admin.js",
|
||||
"app-bootstrap.js",
|
||||
]
|
||||
APP_SCRIPTS = [APP_DIR / name for name in APP_SCRIPT_NAMES]
|
||||
OPERATOR_LABELS_JS = APP_DIR / "operator-labels.js"
|
||||
SUBMISSION_IMPORT_JS = APP_DIR / "submission-import.js"
|
||||
EVIDENCE_GUIDANCE_JS = APP_DIR / "evidence-guidance.js"
|
||||
|
|
@ -33,10 +53,20 @@ def _read(path: Path) -> str:
|
|||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _read_app() -> str:
|
||||
"""The whole operator app script, whichever part a given snippet now lives in.
|
||||
|
||||
These specs assert that a behavior is present in the console, not which file
|
||||
holds it, so they read the parts as one text in load order.
|
||||
"""
|
||||
return "\n".join(_read(path) for path in APP_SCRIPTS)
|
||||
|
||||
|
||||
def test_operator_workbench_files_exist():
|
||||
assert INDEX.exists()
|
||||
assert STYLES.exists()
|
||||
assert APP_JS.exists()
|
||||
for path in APP_SCRIPTS:
|
||||
assert path.exists(), path.name
|
||||
assert OPERATOR_LABELS_JS.exists()
|
||||
assert SUBMISSION_IMPORT_JS.exists()
|
||||
assert EVIDENCE_GUIDANCE_JS.exists()
|
||||
|
|
@ -63,7 +93,7 @@ def test_operator_gui_text_does_not_include_mojibake_fragments():
|
|||
"?",
|
||||
]
|
||||
|
||||
for path in [INDEX, APP_JS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
|
||||
for path in [INDEX, *APP_SCRIPTS, OPERATOR_LABELS_JS, SUBMISSION_IMPORT_JS, EVIDENCE_GUIDANCE_JS, OPERATOR_SEARCH_JS]:
|
||||
text = _read(path)
|
||||
for fragment in suspect_fragments:
|
||||
assert fragment not in text, f"{path.name} contains mojibake fragment {fragment!r}"
|
||||
|
|
@ -154,7 +184,7 @@ def test_workbench_shell_exposes_all_internal_operator_views():
|
|||
|
||||
|
||||
def test_static_app_models_core_review_operations():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
for state_name in [
|
||||
"submissions",
|
||||
|
|
@ -193,18 +223,56 @@ def test_static_app_models_core_review_operations():
|
|||
assert f"function {operation}" in script
|
||||
|
||||
|
||||
def test_index_loads_every_app_part_exactly_once_in_declared_order():
|
||||
"""The split is only safe while the load order holds.
|
||||
|
||||
The parts are classic scripts sharing one global scope: app-state.js declares
|
||||
the state the rest read, and app-bootstrap.js is the only part that executes
|
||||
anything at load. Reordering, dropping, or adding a part without updating
|
||||
index.html would break the console at runtime but leave every content-level
|
||||
assertion in this module green, so pin the sequence itself.
|
||||
"""
|
||||
html = _read(INDEX)
|
||||
|
||||
positions = []
|
||||
for name in APP_SCRIPT_NAMES:
|
||||
tag = f'<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():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
labels = _read(OPERATOR_LABELS_JS)
|
||||
helpers = _read(SUBMISSION_IMPORT_JS)
|
||||
guidance = _read(EVIDENCE_GUIDANCE_JS)
|
||||
search = _read(OPERATOR_SEARCH_JS)
|
||||
|
||||
first_app_part = f'src="{APP_SCRIPT_NAMES[0]}"'
|
||||
assert html.index('src="operator-labels.js"') < html.index('src="submission-import.js"')
|
||||
assert html.index('src="submission-import.js"') < html.index('src="app.js"')
|
||||
assert html.index('src="evidence-guidance.js"') < html.index('src="app.js"')
|
||||
assert html.index('src="operator-search.js"') < html.index('src="app.js"')
|
||||
assert html.index('src="submission-import.js"') < html.index(first_app_part)
|
||||
assert html.index('src="evidence-guidance.js"') < html.index(first_app_part)
|
||||
assert html.index('src="operator-search.js"') < html.index(first_app_part)
|
||||
assert "OperatorLabels" in labels
|
||||
assert "riskLabels" in labels
|
||||
assert "providerLabels" in labels
|
||||
|
|
@ -231,7 +299,7 @@ def test_operator_gui_extracts_submission_import_helpers_from_main_app():
|
|||
|
||||
|
||||
def test_operator_gui_does_not_boot_with_demo_review_data():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
for collection in [
|
||||
"submissions",
|
||||
|
|
@ -303,7 +371,7 @@ def test_ui_overhaul_final_audit_has_no_overflow_and_required_screenshots():
|
|||
def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
|
||||
html = _read(INDEX)
|
||||
styles = _read(STYLES)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="coverage-tabs"' in html
|
||||
assert 'id="search-coverage"' not in html
|
||||
|
|
@ -318,7 +386,7 @@ def test_header_coverage_uses_horizontal_filter_tabs_not_scrolling_panel():
|
|||
def test_queue_uses_grid_row_contract_for_dense_evidence_and_provider_columns():
|
||||
html = _read(INDEX)
|
||||
styles = _read(STYLES)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "queue-grid" in html
|
||||
assert "queue-row" in script
|
||||
|
|
@ -349,7 +417,7 @@ def test_visual_overhaul_removes_obsolete_header_cards_and_fixed_table_widths():
|
|||
|
||||
def test_case_workbench_owns_evidence_and_query_history_as_internal_tabs():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert 'id="workbench-view"' in html
|
||||
|
|
@ -387,7 +455,7 @@ def test_case_workbench_tabs_do_not_share_the_same_review_layout():
|
|||
|
||||
def test_case_decision_controls_float_while_reviewing_evidence():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "floating-decision-panel" in html
|
||||
|
|
@ -404,7 +472,7 @@ def test_case_decision_controls_float_while_reviewing_evidence():
|
|||
|
||||
|
||||
def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "knowledge-main" in script
|
||||
|
|
@ -423,7 +491,7 @@ def test_knowledge_base_rows_separate_title_metadata_chips_and_actions():
|
|||
|
||||
def test_correction_history_lives_inside_knowledge_database_panel():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert 'data-knowledge-tab="collect"' in html
|
||||
|
|
@ -460,7 +528,7 @@ def test_knowledge_collection_and_registered_entries_are_separate_tabs():
|
|||
|
||||
def test_safety_rules_are_visible_in_ui_contract():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="decision-memo"' in html
|
||||
assert 'id="manual-query-provider"' in html
|
||||
|
|
@ -476,7 +544,7 @@ def test_safety_rules_are_visible_in_ui_contract():
|
|||
|
||||
|
||||
def test_provider_quota_zero_does_not_render_nan_meter():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "provider.quota ? Math.min" in script
|
||||
assert "configuredEnv" in script
|
||||
|
|
@ -484,7 +552,7 @@ def test_provider_quota_zero_does_not_render_nan_meter():
|
|||
|
||||
|
||||
def test_query_history_rerun_preserves_original_search_provider():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
search = _read(OPERATOR_SEARCH_JS)
|
||||
|
||||
assert "data-rerun-provider" in script
|
||||
|
|
@ -497,7 +565,7 @@ def test_query_history_rerun_preserves_original_search_provider():
|
|||
|
||||
def test_operator_gui_exposes_submission_reload_action():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="submission-folder"' in html
|
||||
assert 'id="submission-image"' in html
|
||||
|
|
@ -519,7 +587,7 @@ def test_operator_gui_exposes_submission_reload_action():
|
|||
|
||||
|
||||
def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
|
||||
script = _read(APP_JS) + _read(SUBMISSION_IMPORT_JS)
|
||||
script = _read_app() + _read(SUBMISSION_IMPORT_JS)
|
||||
|
||||
for required_copy in [
|
||||
"제출 폴더를 읽는 중입니다.",
|
||||
|
|
@ -538,7 +606,7 @@ def test_operator_gui_uses_clear_korean_status_copy_for_queue_imports():
|
|||
|
||||
|
||||
def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "function selectedBulkSubmissionIds" in script
|
||||
assert 'input[data-bulk-id]:checked' in script
|
||||
|
|
@ -548,7 +616,7 @@ def test_operator_gui_bulk_rerun_uses_checked_queue_rows():
|
|||
|
||||
def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="knowledge-image"' in html
|
||||
assert 'id="knowledge-image-name"' in html
|
||||
|
|
@ -563,7 +631,7 @@ def test_operator_gui_manual_knowledge_entry_accepts_reference_image():
|
|||
|
||||
def test_operator_gui_exposes_keyword_candidate_collection_workflow():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
search = _read(OPERATOR_SEARCH_JS)
|
||||
|
||||
assert 'id="candidate-collection-form"' in html
|
||||
|
|
@ -612,7 +680,7 @@ def test_operator_gui_exposes_keyword_candidate_collection_workflow():
|
|||
|
||||
def test_google_custom_search_is_exposed_as_operator_text_query_choice():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
search = _read(OPERATOR_SEARCH_JS)
|
||||
labels = _read(OPERATOR_LABELS_JS)
|
||||
|
||||
|
|
@ -638,7 +706,7 @@ def test_audit_target_and_change_columns_have_equal_widths():
|
|||
|
||||
|
||||
def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "provider-strip" in script
|
||||
|
|
@ -655,7 +723,7 @@ def test_queue_provider_judgments_render_on_one_line_with_narrower_reason_column
|
|||
|
||||
|
||||
def test_evidence_operator_status_actions_are_binary_use_or_ignore():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert '["used_for_judgment", "사용"]' in script
|
||||
assert '["ignored", "미사용"]' in script
|
||||
|
|
@ -667,7 +735,7 @@ def test_evidence_operator_status_actions_are_binary_use_or_ignore():
|
|||
|
||||
def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
for english_label in [
|
||||
"Image Rights Operator Console",
|
||||
|
|
@ -702,7 +770,7 @@ def test_operator_gui_uses_korean_operator_copy_for_visible_labels():
|
|||
|
||||
|
||||
def test_operator_gui_renders_search_result_links_and_thumbnails():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "renderEvidenceLink" in script
|
||||
assert "thumbnailUrl" in script
|
||||
|
|
@ -725,7 +793,7 @@ def test_operator_gui_renders_search_result_links_and_thumbnails():
|
|||
|
||||
|
||||
def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "renderEvidenceSummary" in script
|
||||
|
|
@ -740,7 +808,7 @@ def test_operator_gui_prioritizes_evidence_and_hides_overflow_details():
|
|||
|
||||
def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
guidance = _read(EVIDENCE_GUIDANCE_JS)
|
||||
combined_script = script + guidance
|
||||
styles = _read(STYLES)
|
||||
|
|
@ -766,7 +834,7 @@ def test_operator_gui_suggests_followup_queries_for_insufficient_evidence():
|
|||
|
||||
|
||||
def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert "faceCropSearch" in script
|
||||
assert "face_web" in script
|
||||
|
|
@ -775,7 +843,7 @@ def test_operator_gui_separates_face_crop_web_evidence_from_identity_matching():
|
|||
|
||||
|
||||
def test_operator_gui_exposes_evidence_status_and_watchlist_controls():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "/api/evidence/" in script
|
||||
|
|
@ -796,7 +864,7 @@ def test_visual_assets_are_referenced_for_review_images():
|
|||
assets_dir = APP_DIR / "assets"
|
||||
|
||||
assert (assets_dir / "case-portrait.svg").exists()
|
||||
assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read(APP_JS)
|
||||
assert "assets/case-portrait.svg" in html or "assets/case-portrait.svg" in _read_app()
|
||||
|
||||
for orphan_asset in [
|
||||
"case-character.svg",
|
||||
|
|
@ -807,7 +875,7 @@ def test_visual_assets_are_referenced_for_review_images():
|
|||
|
||||
|
||||
def test_suggested_queries_support_one_click_and_batch_execution():
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert "executeSuggestedQueries" in script
|
||||
|
|
@ -823,7 +891,7 @@ def test_suggested_queries_support_one_click_and_batch_execution():
|
|||
|
||||
def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0]
|
||||
assert 'id="knowledge-search"' in registered_panel
|
||||
|
|
@ -843,7 +911,7 @@ def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle():
|
|||
|
||||
def test_workbench_shows_detected_face_crop_strip():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
|
||||
assert 'id="face-crop-strip"' in html
|
||||
assert "faceCrops" in script
|
||||
|
|
@ -856,7 +924,7 @@ def test_workbench_shows_detected_face_crop_strip():
|
|||
|
||||
def test_rerun_diff_summary_and_new_evidence_badges_are_rendered():
|
||||
html = _read(INDEX)
|
||||
script = _read(APP_JS)
|
||||
script = _read_app()
|
||||
styles = _read(STYLES)
|
||||
|
||||
assert 'id="rerun-diff-summary"' in html
|
||||
|
|
|
|||
|
|
@ -1179,7 +1179,12 @@ def test_http_server_runs_auto_search_for_submission(tmp_path: Path):
|
|||
|
||||
|
||||
def test_operator_gui_uses_api_bootstrap():
|
||||
app_js = Path("web/operator-gui/app.js").read_text(encoding="utf-8")
|
||||
# app.js was split into ordered app-*.js parts; this spec cares that the
|
||||
# console bootstraps off the API, not which part holds the call.
|
||||
app_js = "
|
||||
".join(
|
||||
path.read_text(encoding="utf-8") for path in sorted(Path("web/operator-gui").glob("app-*.js"))
|
||||
)
|
||||
|
||||
assert "/api/bootstrap" in app_js
|
||||
assert "refreshFromApi" in app_js
|
||||
|
|
|
|||
282
web/operator-gui/app-admin.js
Normal file
282
web/operator-gui/app-admin.js
Normal 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
264
web/operator-gui/app-api.js
Normal 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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
558
web/operator-gui/app-bootstrap.js
Normal file
558
web/operator-gui/app-bootstrap.js
Normal 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));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
323
web/operator-gui/app-collections.js
Normal file
323
web/operator-gui/app-collections.js
Normal 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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
361
web/operator-gui/app-decision-tools.js
Normal file
361
web/operator-gui/app-decision-tools.js
Normal 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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
591
web/operator-gui/app-format.js
Normal file
591
web/operator-gui/app-format.js
Normal 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("&", "&")
|
||||
|
||||
.replaceAll("<", "<")
|
||||
|
||||
.replaceAll(">", ">")
|
||||
|
||||
.replaceAll('"', """)
|
||||
|
||||
.replaceAll("'", "'");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function safeUrl(value) {
|
||||
|
||||
// Defense-in-depth for URLs derived from external search results: only allow
|
||||
|
||||
// absolute http(s) or same-origin paths into href/src (blocks javascript:,
|
||||
|
||||
// data:, etc.). The server also normalizes these server-side.
|
||||
|
||||
const url = String(value || "").trim();
|
||||
|
||||
// Same-origin path, but reject protocol-relative ("//host") and backslash
|
||||
// ("/\\host") forms that browsers normalize to an external host.
|
||||
const isSameOriginPath = url.startsWith("/") && !url.startsWith("//") && !url.startsWith("/\\");
|
||||
|
||||
if (/^https?:\/\//i.test(url) || isSameOriginPath) {
|
||||
|
||||
return url;
|
||||
|
||||
}
|
||||
|
||||
return "#";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatReason(reason) {
|
||||
const text = String(reason || "");
|
||||
if (!text) return "";
|
||||
|
||||
if (text === "Naver search result found") return "네이버 검색 결과 발견";
|
||||
if (text === "Naver search returned no results") return "네이버 검색 결과 없음";
|
||||
if (text === "Naver blog search result found") return "네이버 블로그 검색 결과 발견";
|
||||
if (text === "Naver blog search returned no results") return "네이버 블로그 검색 결과 없음";
|
||||
if (text === "Naver web search result found") return "네이버 웹문서 검색 결과 발견";
|
||||
if (text === "Naver web search returned no results") return "네이버 웹문서 검색 결과 없음";
|
||||
if (text === "Google custom image search result found") return "구글 이미지 검색 결과 발견";
|
||||
if (text === "Google custom image search returned no results") return "구글 이미지 검색 결과 없음";
|
||||
if (text === "Google custom web search result found") return "구글 웹 검색 결과 발견";
|
||||
if (text === "Google custom web search returned no results") return "구글 웹 검색 결과 없음";
|
||||
if (text.startsWith("Google face crop web evidence: ")) {
|
||||
return `얼굴 영역 웹 근거: ${formatReason(text.replace("Google face crop web evidence: ", ""))}`;
|
||||
}
|
||||
if (text.startsWith("Web entity matched ")) return `웹 엔티티 일치: ${text.replace("Web entity matched ", "")}`;
|
||||
if (text.startsWith("Google weak label ")) return `구글 약한 라벨 ${text.replace("Google weak label ", "")}`;
|
||||
if (text.startsWith("Best guess label ")) return `구글 추정 라벨 ${text.replace("Best guess label ", "")}`;
|
||||
if (text === "LLM summary has no source evidence") return "내부 요약에 연결 근거 없음";
|
||||
if (text === "LLM summary generated") return "내부 요약 생성";
|
||||
if (text.startsWith("LLM assistance failed")) return text.replace("LLM assistance failed", "내부 요약 실패");
|
||||
if (text.includes("Google Web Detection timeout")) return "구글 이미지 감지 시간 초과";
|
||||
if (text === "local submission") return "로컬 제출 이미지";
|
||||
if (text.startsWith("naver.example / rank ")) return text.replace("naver.example / rank ", "네이버 예시 / 순위 ");
|
||||
if (text.startsWith("google.example / page")) return "구글 예시 / 페이지";
|
||||
if (text.startsWith("prior rejection / ")) return text.replace("prior rejection / ", "이전 반려 / ");
|
||||
|
||||
return text
|
||||
.replaceAll("Google Web Detection", "구글 이미지 감지")
|
||||
.replaceAll("Google", "구글")
|
||||
.replaceAll("Naver", "네이버")
|
||||
.replaceAll("google", "구글")
|
||||
.replaceAll("naver", "네이버")
|
||||
.replaceAll("internal", "내부")
|
||||
.replaceAll("local", "로컬")
|
||||
.replaceAll("submission", "제출")
|
||||
.replaceAll("page", "페이지")
|
||||
.replaceAll("weak", "약함")
|
||||
.replaceAll("LLM", "내부 요약")
|
||||
.replaceAll("confidence", "신뢰도")
|
||||
.replaceAll("sourceEvidenceIds", "근거 ID")
|
||||
.replaceAll("rank", "순위");
|
||||
}
|
||||
|
||||
function formatEvidenceTitle(evidence) {
|
||||
|
||||
return formatReason(evidence.title || evidence.reason || "");
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatProviderStatus(status) {
|
||||
|
||||
return providerStatusLabels[status] || status;
|
||||
|
||||
}
|
||||
|
||||
|
||||
function formatProviderName(provider) {
|
||||
return providerLabels[provider.id] || provider.name || provider.id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatQueueProviderStatus(provider, status) {
|
||||
const providerNames = {
|
||||
internal: "내부",
|
||||
naver: "네이버",
|
||||
naver_blog: "블로그",
|
||||
naver_web: "웹문서",
|
||||
google: "구글",
|
||||
google_search: "구글",
|
||||
llm: "요약",
|
||||
};
|
||||
|
||||
const statusNames = {
|
||||
ok: "정상",
|
||||
covered: "근거 있음",
|
||||
empty: "없음",
|
||||
failed: "실패",
|
||||
disabled: "중지",
|
||||
skipped: "미실행",
|
||||
pending: "미실행",
|
||||
not_run: "미실행",
|
||||
};
|
||||
|
||||
return `${providerNames[provider] || providerLabels[provider] || provider} ${statusNames[status] || formatProviderStatus(status)}`;
|
||||
}
|
||||
|
||||
function visibleProviderStateEntries(providerState) {
|
||||
|
||||
return Object.entries(providerState || {}).filter(([provider]) => !retiredProviderIds.has(provider));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function renderOperatorSearchProviderOptions() {
|
||||
const optionHtml = operatorSearchProviders
|
||||
.map((provider) => {
|
||||
const runtime = providers.find((item) => item.id === provider.id);
|
||||
const unavailable = Boolean(runtime) && !runtime.enabled;
|
||||
const label = unavailable ? `${provider.label} (비활성)` : provider.label;
|
||||
const reason = unavailable ? String(runtime.lastFailure || runtime.compliance || "외부 검색 tool 미연결") : "";
|
||||
return `<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 || "-";
|
||||
}
|
||||
|
||||
450
web/operator-gui/app-keyboard-batch.js
Normal file
450
web/operator-gui/app-keyboard-batch.js
Normal 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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
450
web/operator-gui/app-navigation.js
Normal file
450
web/operator-gui/app-navigation.js
Normal 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] : [];
|
||||
|
||||
}
|
||||
|
||||
|
||||
449
web/operator-gui/app-render-evidence.js
Normal file
449
web/operator-gui/app-render-evidence.js
Normal 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>`;
|
||||
}
|
||||
|
||||
364
web/operator-gui/app-render-panels.js
Normal file
364
web/operator-gui/app-render-panels.js
Normal 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 반영";
|
||||
});
|
||||
}
|
||||
|
||||
322
web/operator-gui/app-render-queue.js
Normal file
322
web/operator-gui/app-render-queue.js
Normal 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>
|
||||
|
||||
`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
310
web/operator-gui/app-selectors.js
Normal file
310
web/operator-gui/app-selectors.js
Normal 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;
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
137
web/operator-gui/app-state.js
Normal file
137
web/operator-gui/app-state.js
Normal 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;
|
||||
|
||||
|
||||
291
web/operator-gui/app-stats.js
Normal file
291
web/operator-gui/app-stats.js
Normal 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)}%`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
382
web/operator-gui/app-submissions.js
Normal file
382
web/operator-gui/app-submissions.js
Normal 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
|
|
@ -10,7 +10,28 @@
|
|||
<script src="submission-import.js" defer></script>
|
||||
<script src="evidence-guidance.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>
|
||||
<body data-internal-only="true">
|
||||
<div class="auth-gate" id="auth-gate" hidden role="dialog" aria-modal="true" aria-labelledby="auth-gate-title">
|
||||
|
|
|
|||
Loading…
Reference in a new issue