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

55 KiB

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 말미에 추가:

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) 아래에 추가:

let suggestedQueryRunSummary = null; // { caseId, message } — 현재 케이스의 마지막 추천 쿼리 실행 결과

(3b) renderEvidenceNextActions 전체를 다음으로 교체:

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 함수 아래에 새 함수 추가:

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) 바로 위에 추가:

    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로 전체 대입 위치 확인)마다 대입 직후에 추가:

  suggestedQueryRunSummary = null;

(3f) web/operator-gui/styles.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: 커밋
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 한 줄을 다음으로 교체:

    # 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) 전체를 다음으로 교체:

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 교체:

const operatorSearchProviders = [
  { id: "naver", label: providerLabels.naver },
  { id: "google_search", label: providerLabels.google_search },
];

(retiredProviderIds는 그대로 둔다 — 큐 화면의 제공자 상태 칩 밀도 유지용이며 안전 규칙이 아니다.)

(3b) renderOperatorSearchProviderOptions 전체 교체 — 어댑터 미설정/비활성 제공자는 보이되 비활성 처리:

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.jsnormalizeManualSearchProvider 교체:

  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: 커밋
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 말미에 추가:

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.pyexclude_watchlist_entry 메서드(~line 1074) 바로 뒤에 추가:

    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": 분기 에 추가:

                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: 에 추가:

                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: 커밋
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 말미에 추가:

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 패널을 다음으로 교체:

            <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 선언 아래)에 추가:

const knowledgeFilters = { query: "", type: "all", status: "all", active: "all" };
let editingKnowledgeEntryId = "";

(4b) renderKnowledgeBase 위에 필터 함수 추가:

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를 다음으로 교체 (행 템플릿의 썸네일/칩/메타 부분은 기존과 동일하며, 목록 소스·액션·편집 폼만 다르다):

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, 클라이언트 전용 토글)를 삭제하고 그 자리에 추가:

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)를 다음으로 교체:

    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)를 다음으로 교체:

  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)에 추가:

  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 말미에 추가:

.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: 커밋
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 클래스 아래에 정의):

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) 아래에 추가:

        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) 뒤에 추가:

    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)에 추가:

    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_derivativesHeuristicFacePersonDetector는 sqlite_store.py에 이미 import되어 있다. 박스별로 1개씩 크롭을 만들어 실패한 박스를 건너뛰어도 index-박스 대응이 어긋나지 않게 한다.)

(2d) seed_from_image_store: self._refresh_existing_local_face_evidence(...) 호출(~line 677) 아래에 기존 제출 백필 추가:

        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) 아래에 추가:

            self._sync_face_crops(record["id"], image_store)

(2e) _rerun_internal_analysis: self._rescore_submission(submission_id) (~line 1872) 아래, return domain_evidence 앞에 추가:

        self._sync_face_crops(submission_id, image_store)

(2f) _google_face_crop_web_detection(~line 1938)에서 다음 3줄을:

        signal = HeuristicFacePersonDetector().detect(original_image)
        if not signal.face_boxes:
            return [], 0

        crops = build_face_crop_derivatives(original_image, signal.face_boxes)

다음으로 교체 (저장된 감지 결과 재사용 — 스펙의 "크롭 생성 일원화"):

        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 루프 앞에 추가:

        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/ 분기 뒤에 추가:

                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 OneFaceDetectorface_boxes=()라 기존 테스트의 faceCrops는 빈 배열로 채워질 뿐 다른 동작 변화 없음)

  • Step 5: 실패하는 GUI 계약 테스트 작성

tests/operator_gui/test_static_workbench.py 말미에 추가:

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> 아래에 추가:

                <div class="similar-strip face-crop-strip" id="face-crop-strip" aria-label="감지된 얼굴 영역"></div>

(6b) app.js renderCaseReview의 similar-strip 렌더 블록(~line 1245-1263) 아래에 추가:

  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 초기화 줄 아래)에 추가:

  document.getElementById("face-crop-strip").innerHTML = "";

(6d) styles.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: 커밋
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 말미에 추가:

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) 바로 아래에 추가:

        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) 를 다음으로 교체:

        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 말미에 추가:

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) 바로 위에 추가:

                <div id="rerun-diff-summary" class="rerun-diff-summary"></div>

(5b) app.js 모듈 상단(Task 4의 editingKnowledgeEntryId 아래)에 추가:

let activeRerunAddedIds = new Set(); // 현재 렌더 중인 케이스의 lastRerunDiff.addedEvidenceIds

(5c) renderCaseReview에서 document.getElementById("case-reasons").innerHTML = ...에 추가:

  activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);
  document.getElementById("rerun-diff-summary").innerHTML = submission.lastRerunDiff
    ? renderRerunDiffSummary(submission.lastRerunDiff)
    : "";

(5d) renderEvidenceSearch에서 results.innerHTML = ...에 추가:

  activeRerunAddedIds = new Set(submission.lastRerunDiff?.addedEvidenceIds || []);

(5e) renderNoSelectedCase에 추가:

  activeRerunAddedIds = new Set();
  document.getElementById("rerun-diff-summary").innerHTML = "";

(5f) renderEvidenceRow에서 const sourceEvidenceIds = ... 줄 앞에 추가:

  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 함수 앞에 추가:

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 말미에 추가:

.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: 커밋
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: 미커밋 변경 확인 및 마무리 커밋
git status
git add -A
git commit -m "test: full regression pass for workbench efficiency round"

(미커밋 변경이 없으면 마무리 커밋은 생략한다.)