POSA_Copyrighter/docs/superpowers/plans/2026-06-12-operator-workbench-efficiency.md

1364 lines
55 KiB
Markdown

# 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
? `<div class="inline-status" id="suggested-query-run-result">${escapeHtml(suggestedQueryRunSummary.message)}</div>`
: "";
if (!evidenceNeedsFollowup(submission)) return summary;
const queries = suggestedEvidenceQueries(submission);
const reasons = evidenceFollowupReasons(submission);
if (!queries.length) return summary;
return `
<section class="evidence-next-action-panel" aria-label="근거 보강 추천">
<div>
<strong>근거 보강 추천</strong>
<span>추천 쿼리를 바로 실행하거나, 쿼리 본문을 눌러 수동 검색 입력칸에서 수정할 수 있습니다.</span>
</div>
<ul class="evidence-followup-reasons">
${reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join("")}
</ul>
<div class="suggested-query-list">
${queries
.map(
(query) => `
<span class="suggested-query-item">
<button class="row-action" type="button" data-suggested-query="${escapeHtml(query)}" data-suggested-provider="naver">
${escapeHtml(query)}
</button>
<button class="row-action" type="button" data-run-suggested-query="${escapeHtml(query)}">바로 실행</button>
</span>
`,
)
.join("")}
</div>
<div class="suggested-query-actions">
<button class="row-action" type="button" id="run-all-suggested-queries">모두 실행</button>
</div>
<div id="suggested-query-run-status" class="inline-status" aria-live="polite"></div>
${summary}
</section>
`;
}
```
(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 `<option value="${escapeHtml(provider.id)}" ${unavailable ? "disabled" : ""}>${escapeHtml(label)}</option>`;
})
.join("");
["manual-query-provider", "collection-provider"].forEach((elementId) => {
const select = document.getElementById(elementId);
if (!select) return;
const current = select.value;
select.innerHTML = optionHtml;
select.value = operatorSearchProviders.some((provider) => provider.id === current) ? current : operatorSearchProviders[0].id;
if (select.selectedOptions[0] && select.selectedOptions[0].disabled) {
select.value = operatorSearchProviders[0].id;
}
});
}
```
(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
<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="public_figure">연예인/유명인</option>
<option value="work">작품</option>
<option value="character">캐릭터</option>
<option value="game">게임</option>
<option value="rejected_image">반려 이미지</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>
```
- [ ] **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"
? `<span>원 케이스: ${escapeHtml(entry.sourceSubmissionId || "-")}</span><span>미래 검출 ${escapeHtml(entry.contributionCount || 0)}건</span>`
: "";
const memoInline = entry.memo && entry.memo.startsWith("외부 후보 수집에서 반영:") ? `<span>${escapeHtml(entry.memo)}</span>` : "";
const memoBlock = memoInline ? "" : `<p class="muted small">${escapeHtml(entry.memo)}</p>`;
const lifecycleActions =
entryStatus === "watchlist"
? `
<button class="row-action" type="button" data-promote-watchlist="${escapeHtml(entry.id)}">확정 DB 반영</button>
<button class="row-action danger" type="button" data-exclude-watchlist="${escapeHtml(entry.id)}">오탐 제외</button>
`
: entryStatus === "excluded"
? ""
: entry.active
? `<button class="row-action danger" type="button" data-deactivate-kb="${escapeHtml(entry.id)}">비활성</button>`
: `<button class="row-action" type="button" data-reactivate-kb="${escapeHtml(entry.id)}">재활성</button>`;
const editForm =
editingKnowledgeEntryId === entry.id
? `
<form class="knowledge-edit-form" data-knowledge-edit-form="${escapeHtml(entry.id)}">
<label>
<span>별칭</span>
<input name="aliases" type="text" value="${escapeHtml(aliases.join(", "))}">
</label>
<label>
<span>검색 키워드</span>
<input name="keywords" type="text" value="${escapeHtml(keywords.join(", "))}">
</label>
<label class="wide-field">
<span>정책 메모</span>
<textarea name="memo" rows="3">${escapeHtml(entry.memo || "")}</textarea>
</label>
<div class="knowledge-edit-actions">
<button class="primary-action" type="submit">저장</button>
<button class="secondary-action" type="button" data-cancel-edit-kb="${escapeHtml(entry.id)}">취소</button>
</div>
</form>
`
: "";
return `
<article class="knowledge-row ${entry.active ? "" : "inactive"} ${entryStatus === "watchlist" ? "watchlist" : ""}">
${
entry.imageAsset
? `<img class="knowledge-thumb" src="${escapeHtml(entry.imageAsset)}" alt="${escapeHtml(entry.name)} 참조 이미지">`
: `<div class="knowledge-thumb empty" aria-hidden="true"></div>`
}
<div class="knowledge-main">
<div class="row-title knowledge-title">
<span>${escapeHtml(entry.name)}</span>
</div>
<div class="knowledge-detail-line">
<div class="knowledge-chip-row">
<span class="provenance-chip ${escapeHtml(entry.provenance)}">${escapeHtml(formatProvenance(entry.provenance))}</span>
<span class="watchlist-chip ${escapeHtml(entryStatus)}">${escapeHtml(formatKnowledgeStatus(entryStatus))}</span>
</div>
<div class="row-meta knowledge-meta">
<span>${escapeHtml(formatKnowledgeType(entry.type))}</span>
<span>별칭: ${escapeHtml(aliases.join(", ") || "-")}</span>
<span>키워드: ${escapeHtml(keywords.join(", ") || "-")}</span>
<span>샘플 지문 ${(entry.sampleFingerprints || []).length}</span>
${statusMeta}
${memoInline}
</div>
</div>
${memoBlock}
${editForm}
</div>
<div class="knowledge-actions">
<button class="row-action" type="button" data-edit-kb="${escapeHtml(entry.id)}">편집</button>
${lifecycleActions}
</div>
</article>
`;
})
.join("")
: `<div class="empty-state">조건에 맞는 기준 항목이 없습니다.</div>`;
renderCollectionCandidates();
}
```
(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)
분석 시 감지된 얼굴 박스로 크롭을 디스크에 저장하고(`<db dir>/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의 `<div class="similar-strip" id="similar-strip" ...></div>` 아래에 추가:
```html
<div class="similar-strip face-crop-strip" id="face-crop-strip" aria-label="감지된 얼굴 영역"></div>
```
(6b) `app.js` `renderCaseReview`의 similar-strip 렌더 블록(~line 1245-1263) 아래에 추가:
```js
document.getElementById("face-crop-strip").innerHTML = (submission.faceCrops || [])
.map(
(crop) => `
<div class="similar-item face-crop-item">
<img src="${escapeHtml(crop.url)}" alt="얼굴 영역 ${escapeHtml(String(crop.index))} 크롭">
<span>얼굴 영역 ${escapeHtml(String(crop.index))} · 동일 인물 판정 아님</span>
</div>
`,
)
.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의 `<div id="case-reasons" class="reason-list"></div>` (~line 245) 바로 위에 추가:
```html
<div id="rerun-diff-summary" class="rerun-diff-summary"></div>
```
(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 ? `<span class="evidence-new-chip">신규</span>` : "";
```
그리고 같은 함수의 반환 템플릿에서 두 곳 수정:
- `<article class="evidence-row ${hasPreview ? "" : "no-preview"}"``<article class="evidence-row ${hasPreview ? "" : "no-preview"} ${isNewFromRerun ? "evidence-row-new" : ""}"`
- `evidence-title` div 안에서 `<span class="source-chip ...>...</span>` 바로 뒤에 `${newChip}` 삽입
(5g) `renderRerunDiffSummary` 함수를 `renderEvidenceSummary` 함수 앞에 추가:
```js
function renderRerunDiffSummary(diff) {
const scoreBefore = Number(diff.scoreBefore || 0);
const scoreAfter = Number(diff.scoreAfter || 0);
const delta = scoreAfter - scoreBefore;
const direction = delta > 0 ? `상승 ${delta}` : delta < 0 ? `하락 ${Math.abs(delta)}` : "변동 없음";
const removedSummaries = diff.removedSummaries || [];
const removedBlock = removedSummaries.length
? `
<details class="rerun-removed">
<summary>이번 재분석에서 제거됨 · ${removedSummaries.length}개</summary>
<ul>
${removedSummaries
.map((item) => `<li>${escapeHtml(sourceLabels[item.source] || item.source || "내부")} · ${escapeHtml(formatReason(item.title || ""))}</li>`)
.join("")}
</ul>
</details>
`
: "";
return `
<section class="rerun-diff-panel" aria-label="재분석 변경 요약">
<strong>재분석 ${escapeHtml(diff.at || "")}</strong>
<span>점수 ${escapeHtml(String(scoreBefore))}${escapeHtml(String(scoreAfter))} (${escapeHtml(direction)})</span>
<span>신규 증거 ${(diff.addedEvidenceIds || []).length}개</span>
${removedBlock}
</section>
`;
}
```
(점수 변화 방향은 색이 아니라 "상승/하락" 텍스트로 표기 — 기존 접근성 규칙 준수.)
(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"
```
(미커밋 변경이 없으면 마무리 커밋은 생략한다.)