diff --git a/docs/superpowers/plans/2026-06-12-operator-workbench-efficiency.md b/docs/superpowers/plans/2026-06-12-operator-workbench-efficiency.md new file mode 100644 index 0000000..a35c526 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-operator-workbench-efficiency.md @@ -0,0 +1,1364 @@ +# 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" +``` + +(미커밋 변경이 없으면 마무리 커밋은 생략한다.)