# Operator Workbench Efficiency (F1-F5) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 운영자 워크벤치의 검색 보강 수작업을 줄이고(추천 쿼리 원클릭 실행, Google 수동 검색 재노출) 케이스 판단 속도를 높인다(얼굴 크롭 썸네일, 재분석 증거 diff, KB 정비). **Architecture:** 백엔드는 `CopyrighterStore`(SQLite, JSON 페이로드)에 메서드를 추가하고 `http_app.py`에 라우트를 잇는 기존 패턴을 그대로 따른다. 프런트는 `web/operator-gui/app.js`의 문자열 템플릿 렌더 + 이벤트 위임 패턴을 따른다. 스펙: `docs/superpowers/specs/2026-06-11-operator-workbench-efficiency-design.md`. **Tech Stack:** Python 3.13 표준 라이브러리(http.server, sqlite3), Pillow, pytest / 바닐라 JS(프레임워크 없음), 정적 UI 계약 테스트(`tests/operator_gui/test_static_workbench.py`). **실행 명령 공통:** 저장소 루트(`C:\Users\USER\Desktop\munsang\copyrighter`)에서 실행. 테스트는 `python -m pytest <경로> -v`. --- ### Task 1: F1 — 추천 쿼리 바로 실행 / 모두 실행 근거 보강 추천 패널의 쿼리 버튼은 현재 수동 검색 입력칸을 채우기만 한다(`applySuggestedQuery`). 쿼리별 "바로 실행"과 패널 "모두 실행"을 추가한다. 서버 변경 없음 — 기존 `/api/search/manual`을 재사용한다. **Files:** - Modify: `web/operator-gui/app.js` (renderEvidenceNextActions ~line 890, 모듈 상단 ~line 28, 클릭 위임 ~line 3550, 케이스 선택 ~line 2130) - Modify: `web/operator-gui/styles.css` (말미에 추가) - Test: `tests/operator_gui/test_static_workbench.py` - [ ] **Step 1: 실패하는 UI 계약 테스트 작성** `tests/operator_gui/test_static_workbench.py` 말미에 추가: ```python def test_suggested_queries_support_one_click_and_batch_execution(): script = _read(APP_JS) styles = _read(STYLES) assert "executeSuggestedQueries" in script assert "data-run-suggested-query" in script assert 'id="run-all-suggested-queries"' in script assert "모두 실행" in script assert "바로 실행" in script assert "suggested-query-run-status" in script # 기존 "입력칸 채우기" 버튼은 수정용으로 유지된다. assert "data-suggested-query" in script assert ".suggested-query-item" in styles ``` - [ ] **Step 2: 테스트 실패 확인** Run: `python -m pytest tests/operator_gui/test_static_workbench.py::test_suggested_queries_support_one_click_and_batch_execution -v` Expected: FAIL (`assert "executeSuggestedQueries" in script` AssertionError) - [ ] **Step 3: app.js 구현** (3a) 모듈 상단, `const searchCoverage = {};` (line ~28) 아래에 추가: ```js let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과 ``` (3b) `renderEvidenceNextActions` 전체를 다음으로 교체: ```js function renderEvidenceNextActions(submission) { const summary = suggestedQueryRunSummary && suggestedQueryRunSummary.caseId === submission.id ? `
${escapeHtml(suggestedQueryRunSummary.message)}
` : ""; if (!evidenceNeedsFollowup(submission)) return summary; const queries = suggestedEvidenceQueries(submission); const reasons = evidenceFollowupReasons(submission); if (!queries.length) return summary; return `
근거 보강 추천 추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.
${queries .map( (query) => ` `, ) .join("")}
${summary}
`; } ``` (3c) `applySuggestedQuery` 함수 아래에 새 함수 추가: ```js async function executeSuggestedQueries(queries) { 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 = "네이버 외부 검색 tool이 비활성입니다."; return; } const results = []; for (const [index, query] of queries.entries()) { if (statusTarget) statusTarget.textContent = `"${query}" 실행 중… (${index + 1}/${queries.length})`; try { await apiJson("/api/search/manual", { method: "POST", body: JSON.stringify({ submission_id: submission.id, provider: "naver", query }), }); results.push(`"${query}" 완료`); } catch (errorValue) { results.push(`"${query}" 실패: ${errorValue.message}`); } } suggestedQueryRunSummary = { caseId: submission.id, message: `추천 쿼리 실행 — ${results.join(" · ")}`, }; await refreshFromApi(); } ``` 쿼리는 순차 실행한다(쿼터 계측·상태 표시 단순화). 일부 실패해도 나머지는 계속 실행하고 쿼리별 결과를 모아 표시한다. (3d) 문서 클릭 위임부(기존 `data-rerun-query` 처리 분기, ~line 3550) 바로 위에 추가: ```js 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; } ``` (3e) 케이스 전환 시 요약 초기화 — `state.selectedCaseId = caseId;` 대입이 있는 함수(~line 2130, `rg -n "state.selectedCaseId = caseId" web/operator-gui/app.js`로 전체 대입 위치 확인)마다 대입 직후에 추가: ```js suggestedQueryRunSummary = null; ``` (3f) `web/operator-gui/styles.css` 말미에 추가: ```css .suggested-query-item { display: inline-flex; gap: 4px; align-items: center; } .suggested-query-actions { margin-top: 8px; } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `python -m pytest tests/operator_gui/test_static_workbench.py -v` Expected: 전체 PASS (신규 테스트 포함, 기존 계약 테스트 무손상) - [ ] **Step 5: 커밋** ```bash git add web/operator-gui/app.js web/operator-gui/styles.css tests/operator_gui/test_static_workbench.py git commit -m "feat: one-click and batch execution for suggested evidence queries" ``` --- ### Task 2: F2 — Google 수동 검색 재노출 서버는 `manual_search`/`collect_keyword_candidates`에서 `google_search`를 이미 지원한다. GUI 안전 계약(수동 검색 선택지에서 google_search 제외)을 **의도적으로 개정**한다(설계 승인 2026-06-11). 이미지 업로드 역검색 금지 단언은 유지한다. 서버 변경 없음. **Files:** - Modify: `web/operator-gui/app.js:15` (operatorSearchProviders), `renderOperatorSearchProviderOptions` (~line 616) - Modify: `web/operator-gui/operator-search.js:28-30` (normalizeManualSearchProvider) - Test: `tests/operator_gui/test_static_workbench.py` (기존 2개 테스트 개정) - [ ] **Step 1: 계약 테스트 개정 (실패 상태로)** (1a) `test_safety_rules_are_visible_in_ui_contract`(~line 455)에서 `assert 'option value="google_search"' not in html` 한 줄을 다음으로 교체: ```python # 2026-06-12 안전 규칙 개정(설계 승인 2026-06-11): 운영자 수동 검색에 # 구글 근거 검색(텍스트 쿼리)을 동적 옵션으로 노출한다. 정적 HTML 기본값은 # 네이버만 유지하고, 이미지 업로드 역검색 금지는 그대로다. assert '{ id: "google_search", label: providerLabels.google_search }' in script ``` (1b) `test_google_custom_search_is_not_exposed_as_operator_choice`(~line 604) 전체를 다음으로 교체: ```python def test_google_custom_search_is_exposed_as_operator_text_query_choice(): html = _read(INDEX) script = _read(APP_JS) search = _read(OPERATOR_SEARCH_JS) labels = _read(OPERATOR_LABELS_JS) # 정적 HTML은 네이버 기본값만 두고 부트스트랩 후 동적으로 채운다. assert 'value="google_search"' not in html assert '{ id: "google_search", label: providerLabels.google_search }' in script assert "구글 근거 검색" in labels assert 'provider === "google_search" ? "google_search" : "naver"' in search assert "reverse search" not in html.lower() assert "operatorSearchProviders" in script assert "visibleProviderControls" in script ``` - [ ] **Step 2: 테스트 실패 확인** Run: `python -m pytest tests/operator_gui/test_static_workbench.py -v -k "safety_rules or google_custom_search"` Expected: 두 테스트 모두 FAIL - [ ] **Step 3: 구현** (3a) `web/operator-gui/app.js:15` 교체: ```js const operatorSearchProviders = [ { id: "naver", label: providerLabels.naver }, { id: "google_search", label: providerLabels.google_search }, ]; ``` (`retiredProviderIds`는 그대로 둔다 — 큐 화면의 제공자 상태 칩 밀도 유지용이며 안전 규칙이 아니다.) (3b) `renderOperatorSearchProviderOptions` 전체 교체 — 어댑터 미설정/비활성 제공자는 보이되 비활성 처리: ```js 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; return ``; }) .join(""); ["manual-query-provider", "collection-provider"].forEach((elementId) => { const select = document.getElementById(elementId); if (!select) return; const current = select.value; select.innerHTML = optionHtml; select.value = operatorSearchProviders.some((provider) => provider.id === current) ? current : operatorSearchProviders[0].id; if (select.selectedOptions[0] && select.selectedOptions[0].disabled) { select.value = operatorSearchProviders[0].id; } }); } ``` (3c) `web/operator-gui/operator-search.js`의 `normalizeManualSearchProvider` 교체: ```js function normalizeManualSearchProvider(provider) { return provider === "google_search" ? "google_search" : "naver"; } ``` (`index.html`은 변경하지 않는다 — 정적 옵션은 네이버 기본값만 두고 부트스트랩 후 동적으로 교체된다.) - [ ] **Step 4: 테스트 통과 확인** Run: `python -m pytest tests/operator_gui/test_static_workbench.py -v` Expected: 전체 PASS. 참고: line 563 부근의 `assert 'option value="google_search"' not in html` 단언이 있는 다른 테스트는 정적 HTML 무변경이므로 그대로 통과한다. - [ ] **Step 5: 커밋** ```bash git add web/operator-gui/app.js web/operator-gui/operator-search.js tests/operator_gui/test_static_workbench.py git commit -m "feat: expose google_search as operator manual text-query provider" ``` --- ### Task 3: F5 서버 — KB 편집 / 비활성 / 재활성 엔드포인트 `knowledge_entries` 항목의 별칭·키워드·메모 수정(PATCH), 비활성화, 재활성화(메모 필수)를 추가한다. 라이프사이클 작업은 `entryStatus == "confirmed"` 항목만 허용한다 — watchlist/excluded는 기존 promote/exclude 흐름을 쓴다. 비활성 항목은 `_knowledge_repository()`(sqlite_store.py:2366-2371)가 이미 `active=False`/`excluded`를 건너뛰므로 점수 계산에서 자동 제외된다(신규 로직 불필요). **Files:** - Modify: `src/rights_filter/server/sqlite_store.py` (`exclude_watchlist_entry` 메서드 뒤, ~line 1074) - Modify: `src/rights_filter/server/http_app.py` (do_POST ~line 155, do_PATCH ~line 168) - Test: `tests/rights_filter/server/test_http_app.py` - [ ] **Step 1: 실패하는 서버 테스트 작성** `tests/rights_filter/server/test_http_app.py` 말미에 추가: ```python def test_knowledge_entry_update_deactivate_reactivate_lifecycle(tmp_path: Path): static_dir, image_store, store = _fixtures(tmp_path) server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir) _start(server) base = f"http://127.0.0.1:{server.server_port}" try: _json( base + "/api/knowledge/manual", method="POST", body={"name": "윈터", "type": "public_figure", "aliases": ["에스파 윈터"], "keywords": ["aespa"], "memo": "초상권 주의"}, ) entry = next(item for item in store._all("knowledge_entries") if item["name"] == "윈터") entry_id = entry["id"] updated = _json( base + f"/api/knowledge/{entry_id}", method="PATCH", body={"aliases": "윈터, winter", "keywords": ["aespa", "윈터 화보"], "memo": "수정된 메모"}, ) updated_entry = next(item for item in updated["knowledgeEntries"] if item["id"] == entry_id) assert updated_entry["aliases"] == ["윈터", "winter"] assert updated_entry["keywords"] == ["aespa", "윈터 화보"] assert updated_entry["memo"] == "수정된 메모" deactivated = _json(base + f"/api/knowledge/{entry_id}/deactivate", method="POST", body={"reason": "중복 항목"}) deactivated_entry = next(item for item in deactivated["knowledgeEntries"] if item["id"] == entry_id) assert deactivated_entry["active"] is False with pytest.raises(Exception): _json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": ""}) reactivated = _json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": "검토 후 재사용"}) reactivated_entry = next(item for item in reactivated["knowledgeEntries"] if item["id"] == entry_id) assert reactivated_entry["active"] is True with pytest.raises(Exception): _json(base + f"/api/knowledge/{entry_id}", method="PATCH", body={}) events = _json(base + "/api/audit-events") event_names = [event["event"] for event in events] assert "Knowledge entry updated" in event_names assert "Knowledge entry deactivated" in event_names assert "Knowledge entry reactivated" in event_names finally: server.shutdown() ``` - [ ] **Step 2: 테스트 실패 확인** Run: `python -m pytest tests/rights_filter/server/test_http_app.py::test_knowledge_entry_update_deactivate_reactivate_lifecycle -v` Expected: FAIL — PATCH `/api/knowledge/{id}`가 404(`{"error": "not found"}`)를 반환해 `_json`이 HTTPError를 던진다. - [ ] **Step 3: 스토어 메서드 구현** `src/rights_filter/server/sqlite_store.py`의 `exclude_watchlist_entry` 메서드(~line 1074) 바로 뒤에 추가: ```python def update_knowledge_entry(self, entry_id: str, payload: dict[str, Any]) -> dict[str, Any]: entry = self._get("knowledge_entries", entry_id) updates: dict[str, Any] = {} if "aliases" in payload: updates["aliases"] = _text_list(payload.get("aliases")) if "keywords" in payload: updates["keywords"] = _text_list(payload.get("keywords")) if "memo" in payload: updates["memo"] = str(payload.get("memo", "")).strip() if not updates: raise ValueError("aliases, keywords, memo 중 수정할 값이 필요합니다") before = {key: entry.get(key) for key in updates} entry.update(updates) self._put("knowledge_entries", entry_id, entry) self.add_audit_event( "rights.ops", "Knowledge entry updated", str(entry.get("name", entry_id)), f"{json.dumps(before, ensure_ascii=False)} -> {json.dumps(updates, ensure_ascii=False)}", ) return self.bootstrap() def deactivate_knowledge_entry(self, entry_id: str, reason: str = "") -> dict[str, Any]: entry = self._get("knowledge_entries", entry_id) if entry.get("entryStatus", "confirmed") != "confirmed": raise ValueError("확정 DB 항목만 비활성화할 수 있습니다") if not entry.get("active", False): raise ValueError("이미 비활성 상태입니다") entry["active"] = False entry["deactivatedAt"] = _now_label() entry["deactivatedBy"] = "rights.ops" entry["deactivatedReason"] = reason.strip() self._put("knowledge_entries", entry_id, entry) self.add_audit_event( "rights.ops", "Knowledge entry deactivated", str(entry.get("name", entry_id)), reason.strip() or "운영자 비활성화", ) return self.bootstrap() def reactivate_knowledge_entry(self, entry_id: str, reason: str) -> dict[str, Any]: if not reason.strip(): raise ValueError("재활성에는 사유 메모가 필요합니다") entry = self._get("knowledge_entries", entry_id) if entry.get("entryStatus", "confirmed") != "confirmed": raise ValueError("확정 DB 항목만 재활성화할 수 있습니다") if entry.get("active", False): raise ValueError("이미 활성 상태입니다") entry["active"] = True entry["reactivatedAt"] = _now_label() entry["reactivatedBy"] = "rights.ops" entry["reactivatedReason"] = reason.strip() self._put("knowledge_entries", entry_id, entry) self.add_audit_event( "rights.ops", "Knowledge entry reactivated", str(entry.get("name", entry_id)), reason.strip(), ) return self.bootstrap() ``` - [ ] **Step 4: HTTP 라우트 구현** (4a) `src/rights_filter/server/http_app.py` `do_POST`에서 `elif path == "/api/providers/emergency-disable":` 분기 **앞**에 추가: ```python elif path.startswith("/api/knowledge/") and path.endswith("/deactivate"): entry_id = unquote(path.split("/")[3]) self._json(store.deactivate_knowledge_entry(entry_id, str(body.get("reason", "")))) elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"): entry_id = unquote(path.split("/")[3]) self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", "")))) ``` (4b) `do_PATCH`에서 `if path.startswith("/api/providers/"):` 분기의 `else:` **앞**에 추가: ```python elif path.startswith("/api/knowledge/"): entry_id = unquote(path.split("/")[3]) self._json(store.update_knowledge_entry(entry_id, body)) ``` - [ ] **Step 5: 테스트 통과 확인** Run: `python -m pytest tests/rights_filter/server/test_http_app.py::test_knowledge_entry_update_deactivate_reactivate_lifecycle -v` Expected: PASS - [ ] **Step 6: 커밋** ```bash git add src/rights_filter/server/sqlite_store.py src/rights_filter/server/http_app.py tests/rights_filter/server/test_http_app.py git commit -m "feat: knowledge entry update/deactivate/reactivate endpoints with audit events" ``` --- ### Task 4: F5 GUI — KB 검색 / 필터 / 인라인 편집 / 라이프사이클 "등록된 기준" 탭에 검색·필터를 달고, 항목 인라인 편집과 서버 연동 비활성/재활성을 붙인다. 기존 `data-toggle-kb`(클라이언트 전용 토글 — 새로고침 시 유실)는 제거하고 Task 3의 서버 엔드포인트로 대체한다. **Files:** - Modify: `web/operator-gui/index.html` (registered 패널, line 330-337) - Modify: `web/operator-gui/app.js` (모듈 상단, renderKnowledgeBase ~line 1706, toggleKnowledgeEntry ~line 3138 제거, 클릭 위임 ~line 3467, disable-derived ~line 3393, 이벤트 와이어링) - Modify: `web/operator-gui/styles.css` - Test: `tests/operator_gui/test_static_workbench.py` - [ ] **Step 1: 사전 점검 — 제거 대상 참조 확인** Run: `rg -n "toggle-kb|toggleKnowledgeEntry" tests web/operator-gui` Expected: `web/operator-gui/app.js`에서만 검출(테스트에는 없음). 테스트에서 검출되면 해당 단언을 Step 3의 신규 버튼(`data-deactivate-kb`/`data-reactivate-kb`)으로 함께 갱신한다. - [ ] **Step 2: 실패하는 UI 계약 테스트 작성** `tests/operator_gui/test_static_workbench.py` 말미에 추가: ```python def test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle(): html = _read(INDEX) script = _read(APP_JS) registered_panel = html.split('data-knowledge-panel="registered"', 1)[1].split('data-knowledge-panel="manual"', 1)[0] assert 'id="knowledge-search"' in registered_panel assert 'id="knowledge-type-filter"' in registered_panel assert 'id="knowledge-status-filter"' in registered_panel assert 'id="knowledge-active-filter"' in registered_panel assert "filteredKnowledgeEntries" in script assert "data-edit-kb" in script assert "data-knowledge-edit-form" in script assert "saveKnowledgeEntryEdit" in script assert "data-deactivate-kb" in script assert "data-reactivate-kb" in script assert "재활성 사유 메모" in script assert "data-toggle-kb" not in script ``` Run: `python -m pytest tests/operator_gui/test_static_workbench.py::test_registered_knowledge_panel_supports_search_filter_edit_and_lifecycle -v` Expected: FAIL - [ ] **Step 3: index.html — registered 패널 교체** line 330-337의 registered 패널을 다음으로 교체: ```html ``` - [ ] **Step 4: app.js 구현** (4a) 모듈 상단(Task 1의 `suggestedQueryRunSummary` 선언 아래)에 추가: ```js const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" }; let editingKnowledgeEntryId = ""; ``` (4b) `renderKnowledgeBase` 위에 필터 함수 추가: ```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; }); } ``` (4c) `renderKnowledgeBase`를 다음으로 교체 (행 템플릿의 썸네일/칩/메타 부분은 기존과 동일하며, 목록 소스·액션·편집 폼만 다르다): ```js function renderKnowledgeBase() { const entries = filteredKnowledgeEntries(); document.getElementById("knowledge-list").innerHTML = entries.length ? entries .map((entry) => { const aliases = entry.aliases || []; const keywords = entry.keywords || []; const entryStatus = entry.entryStatus || "confirmed"; const statusMeta = entryStatus === "watchlist" ? `원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}미래 검출 ${escapeHtml(entry.contributionCount || 0)}건` : ""; const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `${escapeHtml(entry.memo)}` : ""; const memoBlock = memoInline ? "" : `

${escapeHtml(entry.memo)}

`; const lifecycleActions = entryStatus === "watchlist" ? ` ` : entryStatus === "excluded" ? "" : entry.active ? `` : ``; const editForm = editingKnowledgeEntryId === entry.id ? `
` : ""; return `
${ entry.imageAsset ? `${escapeHtml(entry.name)} 참조 이미지` : `` }
${escapeHtml(entry.name)}
${escapeHtml(formatProvenance(entry.provenance))} ${escapeHtml(formatKnowledgeStatus(entryStatus))}
${escapeHtml(formatKnowledgeType(entry.type))} 별칭: ${escapeHtml(aliases.join(", ") || "-")} 키워드: ${escapeHtml(keywords.join(", ") || "-")} 샘플 지문 ${(entry.sampleFingerprints || []).length} ${statusMeta} ${memoInline}
${memoBlock} ${editForm}
${lifecycleActions}
`; }) .join("") : `
조건에 맞는 기준 항목이 없습니다.
`; renderCollectionCandidates(); } ``` (4d) `toggleKnowledgeEntry` 함수(~line 3138, 클라이언트 전용 토글)를 **삭제**하고 그 자리에 추가: ```js 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) { try { const payload = await apiJson(`/api/knowledge/${encodeURIComponent(entryId)}/deactivate`, { method: "POST", body: JSON.stringify({ reason: "" }), }); 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); } } ``` (4e) 클릭 위임부의 `data-toggle-kb` 분기(~line 3467)를 다음으로 교체: ```js 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; } ``` (4f) `disable-derived` 핸들러(~line 3393)를 다음으로 교체: ```js document.getElementById("disable-derived").addEventListener("click", () => { const automaticEntry = knowledgeEntries.find( (entry) => entry.provenance === "automatic" && entry.active && (entry.entryStatus || "confirmed") === "confirmed", ); if (automaticEntry) void deactivateKnowledgeEntry(automaticEntry.id); }); ``` (4g) 이벤트 와이어링부(`document.getElementById("knowledge-form").addEventListener(...)` 부근, ~line 3380)에 추가: ```js 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); }); ``` (4h) `styles.css` 말미에 추가: ```css .knowledge-filter-bar { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 12px; } .knowledge-edit-form { display: grid; gap: 8px; margin-top: 8px; padding: 10px; border: 1px solid #d4d4d8; border-radius: 8px; } .knowledge-edit-actions { display: flex; gap: 8px; } ``` - [ ] **Step 5: 테스트 통과 확인** Run: `python -m pytest tests/operator_gui/ -v` Expected: 전체 PASS - [ ] **Step 6: 커밋** ```bash git add web/operator-gui/index.html web/operator-gui/app.js web/operator-gui/styles.css tests/operator_gui/test_static_workbench.py git commit -m "feat: knowledge base search/filter, inline edit, and server-backed lifecycle actions" ``` --- ### Task 5: F3 — 얼굴 크롭 썸네일 (서버 + GUI) 분석 시 감지된 얼굴 박스로 크롭을 디스크에 저장하고(`/face-crops/{submission_id}/crop-N.jpg`), `/face-crop-media/` 라우트로 서빙하며, submission 페이로드에 `faceCrops`를 싣는다. Google 얼굴 크롭 웹 탐지는 저장된 박스를 재사용한다(중복 감지 제거). 워크벤치 이미지 패널에 크롭 스트립을 표시한다. **Files:** - Modify: `src/rights_filter/server/sqlite_store.py` (`__init__` ~line 404, `collected_media_path` 뒤 ~line 1710, `_refresh_existing_local_face_evidence` 뒤 ~line 1830, `seed_from_image_store` ~line 677/732, `_rerun_internal_analysis` ~line 1872, `_google_face_crop_web_detection` ~line 1938) - Modify: `src/rights_filter/server/http_app.py` (do_GET, `/collected-media/` 분기 뒤) - Modify: `web/operator-gui/index.html` (~line 237), `web/operator-gui/app.js` (renderCaseReview/renderNoSelectedCase), `web/operator-gui/styles.css` - Test: `tests/rights_filter/server/test_http_app.py`, `tests/operator_gui/test_static_workbench.py` - [ ] **Step 1: 실패하는 서버 테스트 작성** `tests/rights_filter/server/test_http_app.py` 말미에 추가 (`OneFaceBoxDetector`는 기존 `OneFaceDetector` 클래스 아래에 정의): ```python class OneFaceBoxDetector: def detect(self, image): return FacePersonSignal(face_count=1, person_count=1, face_boxes=((40, 40, 120, 120),)) def test_face_crops_are_persisted_served_and_listed_in_review(tmp_path: Path, monkeypatch): monkeypatch.setattr(sqlite_store_module, "HeuristicFacePersonDetector", lambda: OneFaceBoxDetector()) static_dir, image_store, store = _png_fixtures(tmp_path) server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir) _start(server) base = f"http://127.0.0.1:{server.server_port}" try: review = _json(base + "/api/submissions/SUB-API1/review") assert review["faceCrops"], "face crops should be listed for a face submission" crop = review["faceCrops"][0] assert crop["index"] == 1 assert crop["box"] == [40, 40, 120, 120] assert crop["url"].startswith("/face-crop-media/SUB-API1/") with urlopen(base + crop["url"], timeout=5) as response: content = response.read() assert content[:3] == b"\xff\xd8\xff" # JPEG magic bytes finally: server.shutdown() def test_face_crops_are_empty_for_undetectable_images(tmp_path: Path): # autouse OneFaceDetector는 face_boxes를 반환하지 않고, SVG는 PIL로 크롭할 수 없다. static_dir, image_store, store = _fixtures(tmp_path) server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir) _start(server) base = f"http://127.0.0.1:{server.server_port}" try: review = _json(base + "/api/submissions/SUB-API1/review") assert review.get("faceCrops", []) == [] finally: server.shutdown() ``` Run: `python -m pytest tests/rights_filter/server/test_http_app.py -v -k face_crops` Expected: 첫 테스트 FAIL (`KeyError: 'faceCrops'`), 둘째 테스트는 `review.get` 폴백으로 PASS할 수 있음 — 첫 테스트 FAIL 확인이 핵심. - [ ] **Step 2: 스토어 구현** (2a) `__init__`의 `self.collection_public_prefix = ...` (line ~404) 아래에 추가: ```python self.face_crop_image_dir = self.db_path.parent / "face-crops" self.face_crop_public_prefix = "/face-crop-media" ``` (2b) `collected_media_path` 메서드(~line 1709) 뒤에 추가: ```python def face_crop_media_path(self, relative_path: str) -> Path: root = self.face_crop_image_dir.resolve() path = (root / relative_path.lstrip("/")).resolve() if path != root and root not in path.parents: raise ValueError("face crop media path points outside image store") return path ``` (2c) `_refresh_existing_local_face_evidence` 메서드 뒤(~line 1830)에 추가: ```python def _sync_face_crops( self, submission_id: str, image_store: LocalSubmissionImageStore | None, ) -> None: if image_store is None: return try: original = image_store.image_payload(submission_id) except Exception: return signal = HeuristicFacePersonDetector().detect(original) crop_dir = self.face_crop_image_dir / submission_id if crop_dir.exists(): for stale in crop_dir.glob("crop-*.jpg"): stale.unlink() face_crops: list[dict[str, Any]] = [] for index, box in enumerate(signal.face_boxes[:3], start=1): crops = build_face_crop_derivatives(original, [box]) if not crops: continue crop_dir.mkdir(parents=True, exist_ok=True) crop_path = crop_dir / f"crop-{index}.jpg" crop_path.write_bytes(crops[0].content) face_crops.append( { "index": index, "url": f"{self.face_crop_public_prefix}/{submission_id}/crop-{index}.jpg", "box": [int(value) for value in box], } ) submission = self._get("submissions", submission_id) submission["faceCrops"] = face_crops self._put("submissions", submission_id, submission) ``` (`build_face_crop_derivatives`와 `HeuristicFacePersonDetector`는 sqlite_store.py에 이미 import되어 있다. 박스별로 1개씩 크롭을 만들어 실패한 박스를 건너뛰어도 index-박스 대응이 어긋나지 않게 한다.) (2d) `seed_from_image_store`: `self._refresh_existing_local_face_evidence(...)` 호출(~line 677) 아래에 기존 제출 백필 추가: ```python for submission in self._all("submissions", queue_id=queue_id): if "faceCrops" not in submission: self._sync_face_crops(str(submission["id"]), image_store) ``` 그리고 신규 레코드 루프 안의 `self._sync_search_result_image_similarity(record["id"], run.evidence, image_store)` (~line 732) 아래에 추가: ```python self._sync_face_crops(record["id"], image_store) ``` (2e) `_rerun_internal_analysis`: `self._rescore_submission(submission_id)` (~line 1872) 아래, `return domain_evidence` 앞에 추가: ```python self._sync_face_crops(submission_id, image_store) ``` (2f) `_google_face_crop_web_detection`(~line 1938)에서 다음 3줄을: ```python signal = HeuristicFacePersonDetector().detect(original_image) if not signal.face_boxes: return [], 0 crops = build_face_crop_derivatives(original_image, signal.face_boxes) ``` 다음으로 교체 (저장된 감지 결과 재사용 — 스펙의 "크롭 생성 일원화"): ```python submission = self._get("submissions", submission_id) stored_crops = submission.get("faceCrops") if stored_crops is None: signal = HeuristicFacePersonDetector().detect(original_image) face_boxes: tuple = signal.face_boxes else: face_boxes = tuple( tuple(int(value) for value in item.get("box", [])) for item in stored_crops if len(item.get("box", [])) == 4 ) if not face_boxes: return [], 0 crops = build_face_crop_derivatives(original_image, face_boxes) ``` (2g) 거버넌스(스펙 R26): 제출 레코드가 정리될 때 크롭 파일도 함께 삭제한다. `_prune_missing_submission_files`(~line 757)의 `conn.executemany(...)` 블록 뒤, audit 루프 앞에 추가: ```python for submission_id in missing_ids: crop_dir = self.face_crop_image_dir / submission_id if crop_dir.exists(): shutil.rmtree(crop_dir, ignore_errors=True) ``` 파일 상단 import 블록에 `import shutil`이 없으면 추가한다(`rg -n "^import shutil" src/rights_filter/server/sqlite_store.py`로 확인). - [ ] **Step 3: HTTP 라우트 추가** `src/rights_filter/server/http_app.py` `do_GET`의 `/collected-media/` 분기 뒤에 추가: ```python elif path.startswith("/face-crop-media/"): self._file(store.face_crop_media_path(unquote(path.removeprefix("/face-crop-media/"))), untrusted=True) ``` - [ ] **Step 4: 서버 테스트 통과 확인** Run: `python -m pytest tests/rights_filter/server/test_http_app.py -v` Expected: 전체 PASS (기존 테스트 포함 — autouse `OneFaceDetector`는 `face_boxes=()`라 기존 테스트의 `faceCrops`는 빈 배열로 채워질 뿐 다른 동작 변화 없음) - [ ] **Step 5: 실패하는 GUI 계약 테스트 작성** `tests/operator_gui/test_static_workbench.py` 말미에 추가: ```python def test_workbench_shows_detected_face_crop_strip(): html = _read(INDEX) script = _read(APP_JS) assert 'id="face-crop-strip"' in html assert "faceCrops" in script assert "얼굴 영역" in script assert "동일 인물 판정 아님" in script ``` Run: `python -m pytest tests/operator_gui/test_static_workbench.py::test_workbench_shows_detected_face_crop_strip -v` Expected: FAIL - [ ] **Step 6: GUI 구현** (6a) `index.html` line ~237의 `
` 아래에 추가: ```html
``` (6b) `app.js` `renderCaseReview`의 similar-strip 렌더 블록(~line 1245-1263) 아래에 추가: ```js document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || []) .map( (crop) => `
얼굴 영역 ${escapeHtml(String(crop.index))} 크롭 얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정 아님
`, ) .join(""); ``` (6c) `renderNoSelectedCase`(~line 1201, `similar-strip` 초기화 줄 아래)에 추가: ```js document.getElementById("face-crop-strip").innerHTML = ""; ``` (6d) `styles.css` 말미에 추가: ```css .face-crop-strip .face-crop-item img { border: 1px solid #d4d4d8; border-radius: 6px; } ``` - [ ] **Step 7: 전체 테스트 통과 확인** Run: `python -m pytest tests/operator_gui/test_static_workbench.py tests/rights_filter/server/test_http_app.py -v` Expected: 전체 PASS - [ ] **Step 8: 커밋** ```bash git add src/rights_filter/server/sqlite_store.py src/rights_filter/server/http_app.py web/operator-gui/index.html web/operator-gui/app.js web/operator-gui/styles.css tests/rights_filter/server/test_http_app.py tests/operator_gui/test_static_workbench.py git commit -m "feat: persist and display detected face crop thumbnails in workbench" ``` --- ### Task 6: F4 — 재분석 증거 diff `rerun_enrichment`가 실행 전 증거 ID·점수를 스냅샷하고, 완료 후 `lastRerunDiff`를 submission JSON 페이로드에 저장한다. GUI는 점수 변화·신규 배지·제거 요약을 표시한다. 재분석 마커 증거(`ev-{id}-rerun-*`)는 diff에서 제외한다. **Files:** - Modify: `src/rights_filter/server/sqlite_store.py` (`rerun_enrichment`, line 1140-1179) - Modify: `web/operator-gui/index.html` (evidence-pane, ~line 244), `web/operator-gui/app.js`, `web/operator-gui/styles.css` - Test: `tests/rights_filter/server/test_http_app.py`, `tests/operator_gui/test_static_workbench.py` - [ ] **Step 1: 실패하는 서버 테스트 작성** `tests/rights_filter/server/test_http_app.py` 말미에 추가: ```python def test_rerun_enrichment_records_evidence_diff(tmp_path: Path): static_dir, image_store, store = _fixtures(tmp_path) server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir) _start(server) base = f"http://127.0.0.1:{server.server_port}" try: initial = _json(base + "/api/submissions/SUB-API1/review") assert "lastRerunDiff" not in initial rerun = _json(base + "/api/submissions/SUB-API1/rerun-enrichment", method="POST", body={}) diff = rerun["lastRerunDiff"] assert isinstance(diff["scoreBefore"], int) assert isinstance(diff["scoreAfter"], int) assert isinstance(diff["addedEvidenceIds"], list) assert isinstance(diff["removedEvidenceIds"], list) assert isinstance(diff["removedSummaries"], list) assert diff["at"] marker_prefix = "ev-SUB-API1-rerun-" assert all(not evidence_id.startswith(marker_prefix) for evidence_id in diff["addedEvidenceIds"]) finally: server.shutdown() ``` Run: `python -m pytest tests/rights_filter/server/test_http_app.py::test_rerun_enrichment_records_evidence_diff -v` Expected: FAIL (`KeyError: 'lastRerunDiff'`) - [ ] **Step 2: 서버 구현** (2a) `rerun_enrichment`에서 `submission = self._get("submissions", submission_id)` (line 1145) 바로 아래에 추가: ```python score_before = int(submission.get("riskScore", 0) or 0) evidence_before = { str(item.get("id", "")): item for item in self._evidence_by_submission().get(submission_id, []) } ``` (2b) 같은 메서드 끝의 `return self.review(submission_id)` (line 1179) 를 다음으로 교체: ```python evidence_after = { str(item.get("id", "")): item for item in self._evidence_for_submission(submission_id) } rerun_marker_prefix = f"ev-{submission_id}-rerun-" added_ids = [ evidence_id for evidence_id in evidence_after if evidence_id not in evidence_before and not evidence_id.startswith(rerun_marker_prefix) ] removed_items = [ evidence_before[evidence_id] for evidence_id in evidence_before if evidence_id not in evidence_after ] refreshed = self._get("submissions", submission_id) refreshed["lastRerunDiff"] = { "at": _now_label(), "scoreBefore": score_before, "scoreAfter": int(refreshed.get("riskScore", 0) or 0), "addedEvidenceIds": added_ids, "removedEvidenceIds": [str(item.get("id", "")) for item in removed_items], "removedSummaries": [ {"source": str(item.get("source", "")), "title": str(item.get("title", ""))} for item in removed_items ], } self._put("submissions", submission_id, refreshed) return self.review(submission_id) ``` - [ ] **Step 3: 서버 테스트 통과 확인** Run: `python -m pytest tests/rights_filter/server/test_http_app.py -v` Expected: 전체 PASS - [ ] **Step 4: 실패하는 GUI 계약 테스트 작성** `tests/operator_gui/test_static_workbench.py` 말미에 추가: ```python def test_rerun_diff_summary_and_new_evidence_badges_are_rendered(): html = _read(INDEX) script = _read(APP_JS) styles = _read(STYLES) assert 'id="rerun-diff-summary"' in html assert "renderRerunDiffSummary" in script assert "lastRerunDiff" in script assert "evidence-new-chip" in script assert "이번 재분석에서 제거됨" in script assert ".evidence-new-chip" in styles assert ".evidence-row-new" in styles ``` Run: `python -m pytest tests/operator_gui/test_static_workbench.py::test_rerun_diff_summary_and_new_evidence_badges_are_rendered -v` Expected: FAIL - [ ] **Step 5: GUI 구현** (5a) `index.html` evidence-pane의 `
` (~line 245) 바로 위에 추가: ```html
``` (5b) `app.js` 모듈 상단(Task 4의 `editingKnowledgeEntryId` 아래)에 추가: ```js let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds ``` (5c) `renderCaseReview`에서 `document.getElementById("case-reasons").innerHTML = ...` 줄 **앞**에 추가: ```js activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []); document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff ? renderRerunDiffSummary(submission.lastRerunDiff) : ""; ``` (5d) `renderEvidenceSearch`에서 `results.innerHTML = ...` 줄 **앞**에 추가: ```js activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []); ``` (5e) `renderNoSelectedCase`에 추가: ```js activeRerunAddedIds = new Set(); document.getElementById("rerun-diff-summary").innerHTML = ""; ``` (5f) `renderEvidenceRow`에서 `const sourceEvidenceIds = ...` 줄 앞에 추가: ```js const isNewFromRerun = activeRerunAddedIds.has(evidence.id); const newChip = isNewFromRerun ? `신규` : ""; ``` 그리고 같은 함수의 반환 템플릿에서 두 곳 수정: - `
이번 재분석에서 제거됨 · ${removedSummaries.length}개 ` : ""; return `
재분석 ${escapeHtml(diff.at || "")} 점수 ${escapeHtml(String(scoreBefore))} → ${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)}) 신규 증거 ${(diff.addedEvidenceIds || []).length}개 ${removedBlock}
`; } ``` (점수 변화 방향은 색이 아니라 "상승/하락" 텍스트로 표기 — 기존 접근성 규칙 준수.) (5h) `styles.css` 말미에 추가: ```css .rerun-diff-panel { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 10px 12px; border: 1px solid #d4d4d8; border-radius: 8px; margin-bottom: 12px; } .evidence-new-chip { border: 1px solid #1d4ed8; border-radius: 999px; padding: 0 8px; font-size: 12px; color: #1d4ed8; } .evidence-row-new { box-shadow: inset 0 0 0 2px #1d4ed8; } ``` - [ ] **Step 6: 전체 테스트 통과 확인** Run: `python -m pytest tests/operator_gui/test_static_workbench.py tests/rights_filter/server/test_http_app.py -v` Expected: 전체 PASS - [ ] **Step 7: 커밋** ```bash git add src/rights_filter/server/sqlite_store.py web/operator-gui/index.html web/operator-gui/app.js web/operator-gui/styles.css tests/rights_filter/server/test_http_app.py tests/operator_gui/test_static_workbench.py git commit -m "feat: rerun enrichment evidence diff with score delta and new-evidence badges" ``` --- ### Task 7: 전체 회귀 검증 - [ ] **Step 1: 전체 테스트 실행** Run: `python -m pytest tests/ -v` Expected: 전체 PASS (기준선 359개 + 신규 ~7개). 실패 시 실패한 테스트의 단언과 구현을 대조해 수정한다 — 테스트가 옛 계약을 검사하는 경우(예: Task 2의 안전 계약처럼 의도적으로 개정된 것)만 테스트를 고치고, 그 외에는 구현을 고친다. - [ ] **Step 2: 서버 기동 스모크** Run: `python run_copyrighter_server.py` 백그라운드 기동 후 `Invoke-WebRequest http://127.0.0.1:<포트>/health` 로 `{"status": "ok"}` 확인, 종료. (포트는 run_copyrighter_server.py 출력 참조. 자동화 어려우면 생략 가능 — 테스트가 build_server 경로를 이미 커버한다.) - [ ] **Step 3: 미커밋 변경 확인 및 마무리 커밋** ```bash git status git add -A git commit -m "test: full regression pass for workbench efficiency round" ``` (미커밋 변경이 없으면 마무리 커밋은 생략한다.)