POSA_Copyrighter/web/operator-gui/index.html
changukyu 4e8e4b3181 feat: 선례 승계 — 재제출을 다시 심사하지 않는다
8/8 실효성 평가의 결론은 "검색·기록 노동은 줄여주나 심사 건수는 전혀 못 줄인다"
였다. 이 커밋은 그 축을 직접 건드리는 첫 기능이다.

중복 탐지는 이 도구에서 실증으로 검증된 유일한 판별 기능이다 — 재저장(JPEG q90)
/80%축소/50%축소/밝기+10% 변형 24건 중 24건 그룹핑, 같은 24건에서 SHA-256
완전일치는 0건. 그런데 그 결과를 쓰는 곳이 없었다. 서버는 store_duplicates.py
에서 priorDecisions 와 conflicting 을 계산해 내려보내는데, 콘솔은 그것을 큐
배지의 title 속성에만 넣었다. 마우스를 올린 사람만 "이거 지난주에 반려한
이미지"임을 알 수 있었고, 재제출은 처음부터 다시 심사됐다.

서버는 건드리지 않았다. 자리만 옮겼다.

  큐 배지        "동일 이미지 3건 · 선례 반려" (title 이 아니라 본문에)
  판정 패널      결정 메모 바로 위에 선례 배너 + [선례 승계] 1타

승계는 별도 판정 경로를 만들지 않는다. 메모를 채워 기존 setDecision 을 그대로
타므로 반려 메모 필수 검사 · 판정 시점 스냅샷 · 감사 이벤트가 유지되고, 승계
사실은 메모로 감사에 남는다("선례 승계: 동일 이미지 SUB-OLD1, SUB-OLD2 와 같은
판정").

판정 불일치는 승계 대상이 아니다. 같은 이미지에 서로 다른 판정이 있다는 것은
자동 처리할 상황이 아니라 사람이 봐야 하는 신호이므로, conflicting 이면 버튼을
아예 내지 않고 색을 갈라 표시한다.

실렌더 확인(Playwright 1440x900, 실제 서버 + 부트스트랩 스텁):
세 상태(승계 가능 / 판정 불일치 / 선례 없음)의 배너 문구와 버튼 유무가 의도대로,
승계 클릭 시 POST 본문이 위 메모 그대로, 콘솔 에러 0, 가로 오버플로 없음.

검증: ruff clean, pytest 622 passed (정적 3건 추가).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-21 12:05:38 +09:00

567 lines
29 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>권리 검수 콘솔</title>
<link rel="preload" href="assets/fonts/PretendardVariable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="styles.css">
<script src="operator-labels.js" defer></script>
<script src="submission-import.js" defer></script>
<script src="evidence-guidance.js" defer></script>
<script src="operator-search.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">
<form class="auth-gate-card" id="auth-gate-form" autocomplete="off">
<h1 id="auth-gate-title">접근 토큰 입력</h1>
<p class="auth-gate-hint">
이 서버는 인증이 설정되어 있습니다. 운영 담당자에게 받은
<code>COPYRIGHTER_AUTH_TOKEN</code> 값을 입력하세요.
</p>
<label class="auth-gate-label" for="auth-gate-input">접근 토큰</label>
<input class="auth-gate-input" id="auth-gate-input" type="password" required
autocomplete="off" spellcheck="false" aria-describedby="auth-gate-error">
<p class="auth-gate-error" id="auth-gate-error" role="alert" hidden></p>
<button class="primary-action" id="auth-gate-submit" type="submit">인증</button>
</form>
</div>
<div class="app-shell">
<nav class="nav-rail" aria-label="내부 운영 콘솔">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">CR</span>
<div>
<strong>권리 검수기</strong>
<span>권리 심사 콘솔</span>
</div>
</div>
<button class="nav-button active" type="button" data-view="queue" aria-current="page">
<span aria-hidden="true">Q</span>
<span>심사 큐</span>
</button>
<button class="nav-button" type="button" data-view="workbench">
<span aria-hidden="true">C</span>
<span>케이스 심사</span>
</button>
<button class="nav-button" type="button" data-view="knowledge">
<span aria-hidden="true">K</span>
<span>기준 DB</span>
</button>
<button class="nav-button" type="button" data-view="providers">
<span aria-hidden="true">P</span>
<span>외부 검색 tool 활용</span>
</button>
<button class="nav-button" type="button" data-view="stats">
<span aria-hidden="true">S</span>
<span>통계</span>
</button>
<button class="nav-button" type="button" data-view="audit">
<span aria-hidden="true">A</span>
<span>감사 로그</span>
</button>
<div class="nav-footer">
<span class="status-dot ok" aria-hidden="true"></span>
<span>내부 전용</span>
</div>
</nav>
<div class="workspace">
<header class="top-bar">
<div class="product-purpose" aria-label="제품 목적">
<strong>이미지 저작권 위험 심사</strong>
<span>제출 이미지, 외부 검색 근거, 내부 기준 DB를 한 화면에서 검토합니다.</span>
</div>
<div class="queue-health" id="queue-health" aria-live="polite"></div>
<div class="provider-pulse" id="provider-pulse" aria-label="외부 검색 tool 활용 상태"></div>
<div class="coverage-tabs" id="coverage-tabs" aria-label="검색 근거 필터" aria-live="polite"></div>
</header>
<main class="main-surface" id="app-main">
<section class="view active" id="queue-view" data-view-panel="queue" aria-labelledby="queue-title">
<div class="section-header">
<div>
<p class="eyebrow">심사 대기열</p>
<h1 id="queue-title">권리 위험 심사 큐</h1>
<p class="queue-intent" id="queue-upload-guidance">사진을 추가하면 현재 큐에 새 심사 건으로 들어가고, 바로 케이스 심사 화면에서 확인할 수 있습니다.</p>
</div>
<div class="queue-actions">
<label class="queue-folder-input" for="submission-folder">
<span>제출 폴더</span>
<input id="submission-folder" type="text" placeholder="비워두면 현재 큐 재확인">
</label>
<label class="queue-file-input" for="submission-image">
<span>사진 추가</span>
<span id="submission-image-name">선택된 파일 없음</span>
<input id="submission-image" type="file" accept="image/*">
</label>
<button class="secondary-action" type="button" id="upload-submission-image">사진 넣기</button>
<button class="primary-action" type="button" id="reload-submissions">새 제출 불러오기</button>
<button class="secondary-action" type="button" id="bulk-rerun">선택 재분석</button>
</div>
<div class="queue-actions queue-actions-secondary">
<span class="bulk-decision-label" id="bulk-decision-count">선택 0건</span>
<button class="approve-action" type="button" id="bulk-approve" data-bulk-decision="approved">선택 승인</button>
<button class="hold-action" type="button" id="bulk-hold" data-bulk-decision="held">선택 보류</button>
<button class="reject-action" type="button" id="bulk-reject" data-bulk-decision="rejected">선택 반려</button>
<button class="secondary-action" type="button" id="export-decisions">판정 내보내기</button>
<button class="secondary-action" type="button" id="create-backup">백업 만들기</button>
<span class="review-progress" id="review-progress" aria-live="polite"></span>
</div>
</div>
<div class="import-status" id="bulk-decision-status" aria-live="polite"></div>
<pre class="keyboard-help" id="keyboard-help" hidden></pre>
<div class="import-status" id="submission-import-status" aria-live="polite"></div>
<section class="operator-workflow" aria-label="운영 흐름">
<article>
<span>1</span>
<strong>심사 건 추가</strong>
<p>사진 추가 또는 제출 폴더 불러오기로 검토할 이미지를 큐에 올립니다.</p>
</article>
<article>
<span>2</span>
<strong>근거 보강</strong>
<p>근거 부족 사유와 추천 쿼리로 외부 검색 결과를 보강합니다.</p>
</article>
<article>
<span>3</span>
<strong>운영 결정</strong>
<p>승인·보류·반려 결정을 남기고 필요한 경우 기준 DB에 반영합니다.</p>
</article>
</section>
<div class="toolbar" aria-label="큐 필터">
<div class="segmented" id="risk-filter" aria-label="위험도">
<button class="active" type="button" data-risk="all">전체</button>
<button type="button" data-risk="high">높음</button>
<button type="button" data-risk="medium">중간</button>
<button type="button" data-risk="low">낮음</button>
<button type="button" data-risk="failed">실패</button>
<button type="button" data-risk="pending">대기</button>
</div>
<label>
<span>증거 출처</span>
<select id="source-filter">
<option value="all">전체</option>
<option value="naver">네이버 근거</option>
<option value="google">구글 근거</option>
<option value="fingerprint">이미지 유사도</option>
<option value="face">얼굴/인물 감지</option>
<option value="provenance">출처 표기</option>
<option value="llm">내부 요약</option>
<option value="failure">외부 검색 tool 실패</option>
</select>
</label>
<label>
<span>결정 상태</span>
<select id="decision-filter">
<option value="all">전체</option>
<option value="unreviewed">미심사</option>
<option value="held">보류</option>
<option value="rejected">반려</option>
<option value="approved">승인</option>
<option value="corrected">보정됨</option>
</select>
</label>
<label>
<span>정렬</span>
<select id="queue-sort">
<option value="risk">위험도순</option>
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="analysis_failure">분석 실패</option>
<option value="provider_failure">외부 검색 tool 실패</option>
</select>
</label>
<label class="filter-search">
<span>큐 검색</span>
<input id="queue-search" type="search" autocomplete="off" placeholder="SUB-1002, 아이돌, toon...">
</label>
</div>
<div class="table-shell queue-grid-shell">
<table class="queue-table queue-grid">
<thead>
<tr>
<th scope="col"><span class="sr-only">선택</span></th>
<th scope="col">이미지</th>
<th scope="col">제출</th>
<th scope="col">위험</th>
<th scope="col">상위 근거</th>
<th scope="col">외부 검색 tool 활용</th>
<th scope="col">지원자 상태</th>
<th scope="col">운영 결정</th>
<th scope="col">시간</th>
</tr>
</thead>
<tbody id="queue-body"></tbody>
</table>
</div>
</section>
<section class="view" id="workbench-view" data-view-panel="workbench" aria-labelledby="case-title" hidden>
<div class="section-header">
<div>
<p class="eyebrow">케이스 심사</p>
<h1 id="case-title">케이스 심사</h1>
</div>
<div class="case-actions">
<button class="secondary-action" type="button" id="rerun-enrichment">증거 재수집</button>
<button class="secondary-action" type="button" id="open-correction">보정 열기</button>
</div>
</div>
<div class="workbench-tabs" role="tablist" aria-label="케이스 워크벤치">
<button class="active" type="button" data-workbench-tab="evidence">근거 및 판단</button>
<button type="button" data-workbench-tab="queries">검색 이력</button>
</div>
<section class="workbench-panel active" data-workbench-panel="evidence">
<div class="case-layout evidence-layout">
<aside class="pane decision-pane floating-decision-panel" aria-labelledby="decision-pane-title">
<div class="pane-heading floating-decision-head">
<div>
<p class="eyebrow">운영자 판정</p>
<h2 id="decision-pane-title">판정</h2>
</div>
<span class="score-pill risk-pending" id="floating-case-score">대기</span>
</div>
<div class="recommendation-box" id="recommendation-box"></div>
<div class="prior-decision" id="prior-decision-box" hidden></div>
<div class="llm-draft" id="llm-draft" hidden></div>
<label class="memo-field" for="decision-memo">
<span>결정 메모</span>
<textarea id="decision-memo" rows="3" placeholder="반려 또는 보정 시 메모 필수"></textarea>
</label>
<div class="memo-presets" id="memo-presets" aria-label="자주 쓰는 사유"></div>
<p class="form-error" id="memo-error" aria-live="assertive"></p>
<div class="decision-actions">
<button class="approve-action" type="button" data-decision="approved">승인</button>
<button class="hold-action" type="button" data-decision="held">보류</button>
<button class="reject-action" type="button" data-decision="rejected" data-requires-memo="true">반려</button>
</div>
<details class="decision-secondary">
<summary>보조 작업</summary>
<div class="derived-preview" id="derived-preview"></div>
<div class="quick-actions">
<button type="button" id="add-from-case">주의 후보 보기</button>
<button type="button" id="mark-irrelevant">증거 미사용 처리</button>
<button type="button" id="disable-derived">자동 항목 비활성</button>
<button type="button" id="clear-reference">클리어 기준 등록</button>
</div>
</details>
</aside>
<section class="pane image-pane" aria-labelledby="image-pane-title">
<div class="pane-heading">
<h2 id="image-pane-title">제출 이미지</h2>
<div class="icon-actions" aria-label="이미지 보기 제어">
<button type="button" id="fit-image" title="맞춤">맞춤</button>
<button type="button" id="actual-image" title="실제 크기">1:1</button>
<button type="button" id="rotate-image" title="회전">회전</button>
</div>
</div>
<figure class="review-image-frame">
<img id="case-image" src="assets/case-portrait.svg" alt="심사 대상 제출 이미지">
<figcaption id="image-derivative-note"></figcaption>
</figure>
<div class="fact-grid" id="file-facts"></div>
<div class="similar-strip" id="similar-strip" aria-label="유사 이미지"></div>
<div class="similar-strip face-crop-strip" id="face-crop-strip" aria-label="감지된 얼굴 영역"></div>
</section>
<section class="pane evidence-pane" aria-labelledby="evidence-pane-title">
<div class="pane-heading">
<h2 id="evidence-pane-title">증거와 판단 근거</h2>
<span class="score-pill" id="case-score"></span>
</div>
<div id="rerun-diff-summary" class="rerun-diff-summary"></div>
<div id="case-reasons" class="reason-list"></div>
<div id="evidence-next-actions" class="evidence-next-actions"></div>
<div id="evidence-groups" class="evidence-groups"></div>
</section>
</div>
</section>
<section class="workbench-panel" data-workbench-panel="queries" aria-labelledby="evidence-title" hidden>
<div class="section-header">
<div>
<p class="eyebrow">근거 검색</p>
<h1 id="evidence-title">검색 증거 재현</h1>
</div>
</div>
<div class="split-workspace">
<section class="pane">
<div class="pane-heading">
<h2>쿼리 기록</h2>
<span id="query-case-label"></span>
</div>
<div id="query-history" class="stack-list"></div>
</section>
<section class="pane">
<div class="pane-heading">
<h2>수동 텍스트 쿼리</h2>
</div>
<form id="manual-query-form" class="manual-query-form">
<label for="manual-query-provider">
<span>외부 검색 tool 활용</span>
<select id="manual-query-provider">
<option value="naver">네이버 검색</option>
</select>
</label>
<label for="manual-query">
<span>쿼리</span>
<input id="manual-query" type="text" autocomplete="off" placeholder="작품명, 인물명, 키워드">
</label>
<button class="primary-action" type="submit">실행</button>
</form>
<div id="manual-query-status" class="inline-status" aria-live="polite"></div>
<div id="search-results" class="stack-list"></div>
</section>
</div>
</section>
</section>
<section class="view" id="knowledge-view" data-view-panel="knowledge" aria-labelledby="knowledge-title" hidden>
<div class="section-header">
<div>
<p class="eyebrow">기준 데이터베이스</p>
<h1 id="knowledge-title">기준 데이터베이스</h1>
</div>
</div>
<div class="knowledge-tabs" role="tablist" aria-label="기준 데이터베이스">
<button class="active" type="button" data-knowledge-tab="collect">후보 수집</button>
<button type="button" data-knowledge-tab="registered">등록된 기준</button>
<button type="button" data-knowledge-tab="manual">수동 등록</button>
<button type="button" data-knowledge-tab="corrections">보정 기록</button>
</div>
<section class="knowledge-panel active" data-knowledge-panel="collect">
<section class="pane">
<div class="pane-heading">
<h2>키워드 후보 수집</h2>
</div>
<form id="candidate-collection-form" class="candidate-collection-form">
<label for="collection-provider">
<span>외부 검색 tool 활용</span>
<select id="collection-provider">
<option value="naver">네이버 검색</option>
</select>
</label>
<label for="collection-query">
<span>키워드</span>
<input id="collection-query" type="text" autocomplete="off" placeholder="인물명, 그룹명, 작품명">
</label>
<button class="primary-action" type="submit">후보 수집</button>
</form>
<div id="collection-status" class="inline-status" aria-live="polite"></div>
<div class="candidate-selection-actions">
<button class="secondary-action" type="button" id="select-all-candidates">전체 선택</button>
<button class="secondary-action" type="button" id="clear-selected-candidates">전체 선택 취소</button>
</div>
<div id="collection-candidates" class="collection-candidates"></div>
</section>
</section>
<section class="knowledge-panel" data-knowledge-panel="registered" hidden>
<section class="pane">
<div class="pane-heading">
<h2>등록된 기준</h2>
</div>
<div class="knowledge-filter-bar">
<label for="knowledge-search">
<span>검색</span>
<input id="knowledge-search" type="search" autocomplete="off" placeholder="이름, 별칭, 키워드">
</label>
<label for="knowledge-type-filter">
<span>유형</span>
<select id="knowledge-type-filter">
<option value="all">전체</option>
<option value="celebrity">연예인/유명인</option>
<option value="work">작품</option>
<option value="character">캐릭터</option>
<option value="game">게임</option>
<option value="rejected_image">반려 이미지</option>
<option value="other">기타</option>
</select>
</label>
<label for="knowledge-status-filter">
<span>상태</span>
<select id="knowledge-status-filter">
<option value="all">전체</option>
<option value="confirmed">확정 DB</option>
<option value="watchlist">주의 후보</option>
<option value="excluded">제외됨</option>
</select>
</label>
<label for="knowledge-active-filter">
<span>활성</span>
<select id="knowledge-active-filter">
<option value="all">전체</option>
<option value="active">활성</option>
<option value="inactive">비활성</option>
</select>
</label>
</div>
<div id="knowledge-list" class="stack-list"></div>
</section>
</section>
<section class="knowledge-panel" data-knowledge-panel="manual" hidden>
<section class="pane">
<div class="pane-heading compact-heading">
<h2>선택 후보 묶어서 편입</h2>
</div>
<form id="collection-promotion-form" class="collection-promotion-form">
<label>
<span>편입 이름</span>
<input id="collection-promotion-name" type="text" autocomplete="off" placeholder="인물명, 작품명, 캐릭터명">
</label>
<label>
<span>유형</span>
<select id="collection-promotion-type">
<option value="public_figure">연예인/유명인</option>
<option value="work">작품</option>
<option value="character">캐릭터</option>
<option value="game">게임</option>
<option value="rejected_reference">반려 이미지 참조</option>
</select>
</label>
<label>
<span>별칭</span>
<input id="collection-promotion-aliases" type="text" autocomplete="off" placeholder="쉼표로 구분">
</label>
<label>
<span>검색 키워드</span>
<input id="collection-promotion-keywords" type="text" autocomplete="off" placeholder="쉼표로 구분">
</label>
<label class="wide-field">
<span>판단 메모</span>
<textarea id="collection-promotion-memo" rows="3" placeholder="후보를 같은 대상의 샘플로 묶은 이유"></textarea>
</label>
<button class="primary-action" type="submit" id="promote-selected-candidates">선택 후보 묶어서 편입</button>
</form>
<div class="section-divider" aria-hidden="true"></div>
<div class="pane-heading">
<h2>수동 기준 등록</h2>
</div>
<form id="knowledge-form" class="knowledge-form">
<label>
<span>이름</span>
<input id="knowledge-name" type="text" autocomplete="off" required>
</label>
<label>
<span>유형</span>
<select id="knowledge-type">
<option value="public_figure">연예인/유명인</option>
<option value="work">작품</option>
<option value="character">캐릭터</option>
<option value="game">게임</option>
<option value="rejected_reference">반려 이미지 참조</option>
</select>
</label>
<label>
<span>별칭</span>
<input id="knowledge-aliases" type="text" autocomplete="off" placeholder="쉼표로 구분">
</label>
<label>
<span>검색 키워드</span>
<input id="knowledge-keywords" type="text" autocomplete="off" placeholder="쉼표로 구분">
</label>
<label class="file-picker" for="knowledge-image">
<span>참조 이미지</span>
<span class="file-picker-control">
<span class="file-picker-button">이미지 선택</span>
<span id="knowledge-image-name">선택된 파일 없음</span>
</span>
<input id="knowledge-image" class="file-picker-input" type="file" accept="image/*">
</label>
<label>
<span>정책 메모</span>
<textarea id="knowledge-memo" rows="5" required></textarea>
</label>
<button class="primary-action" type="submit">등록</button>
</form>
<div id="knowledge-entry-status" class="inline-status" aria-live="polite"></div>
</section>
</section>
<section class="knowledge-panel" data-knowledge-panel="corrections" hidden>
<div class="pane">
<div class="pane-heading">
<h2>오판 보정 기록</h2>
</div>
<div id="corrections-list" class="stack-list"></div>
</div>
</section>
</section>
<section class="view" id="providers-view" data-view-panel="providers" aria-labelledby="providers-title" hidden>
<div class="section-header">
<div>
<p class="eyebrow">외부 검색 tool 활용</p>
<h1 id="providers-title">외부 검색 tool 활용</h1>
</div>
<button class="danger-outline" type="button" id="emergency-disable">외부 검색 tool 즉시 중지</button>
</div>
<div id="providers-list" class="provider-list"></div>
</section>
<section class="view" id="stats-view" data-view-panel="stats" aria-labelledby="stats-title" hidden>
<div class="section-header">
<div>
<p class="eyebrow">스코어 보정 · 처리량</p>
<h1 id="stats-title">판정 통계</h1>
<p class="section-sub">룰 기반 위험 점수가 실제 운영자 판정과 얼마나 맞는지, 백로그가 어디서 쌓이는지 봅니다.</p>
</div>
</div>
<div id="stats-body" class="stats-body"></div>
</section>
<section class="view" id="audit-view" data-view-panel="audit" aria-labelledby="audit-title" hidden>
<div class="section-header">
<div>
<p class="eyebrow">감사 로그</p>
<h1 id="audit-title">감사 로그</h1>
</div>
</div>
<div class="table-shell">
<table class="audit-table">
<thead>
<tr>
<th scope="col">시간</th>
<th scope="col">행위자</th>
<th scope="col">이벤트</th>
<th scope="col">대상</th>
<th scope="col">변경</th>
</tr>
</thead>
<tbody id="audit-body"></tbody>
</table>
</div>
</section>
</main>
</div>
</div>
</body>
</html>